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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
module Fingerprint
|
|
5
|
+
# Registry of per-platform frame normalizers, reached as
|
|
6
|
+
# `Sentiero.configuration.fingerprint`. `ErrorsApp` resolves an incoming
|
|
7
|
+
# occurrence's optional "platform" tag against this registry to pick the
|
|
8
|
+
# normalizer passed to Fingerprint.compute.
|
|
9
|
+
class Config
|
|
10
|
+
attr_accessor :default_platform
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@normalizers = {}
|
|
14
|
+
@default_platform = "ruby"
|
|
15
|
+
register("ruby", RUBY_NORMALIZER)
|
|
16
|
+
register("crystal", CRYSTAL_NORMALIZER)
|
|
17
|
+
register("generic", GENERIC_NORMALIZER)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Registering an existing name overrides it, so operators may replace a
|
|
21
|
+
# built-in (e.g. swap out "ruby") as well as add new platforms.
|
|
22
|
+
def register(name, callable)
|
|
23
|
+
@normalizers[name.to_s] = callable
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Three-tier resolution (see design doc): a reporter that never learned
|
|
27
|
+
# about the "platform" field must keep grouping exactly as before
|
|
28
|
+
# (absent/blank -> default_platform's normalizer, tier 1), whereas a
|
|
29
|
+
# reporter that declares a platform we don't recognize should not have a
|
|
30
|
+
# foreign grammar mis-applied to it (unregistered -> generic, tier 3).
|
|
31
|
+
def resolve(platform)
|
|
32
|
+
if platform.nil? || platform.to_s.strip.empty?
|
|
33
|
+
@normalizers.fetch(default_platform.to_s, RUBY_NORMALIZER)
|
|
34
|
+
else
|
|
35
|
+
@normalizers.fetch(platform.to_s, GENERIC_NORMALIZER)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/sentiero/fingerprint.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require_relative "fingerprint/config"
|
|
4
5
|
|
|
5
6
|
module Sentiero
|
|
6
7
|
# Computes the grouping fingerprint for an exception occurrence. The
|
|
@@ -12,23 +13,56 @@ module Sentiero
|
|
|
12
13
|
MAX_FRAMES = 5
|
|
13
14
|
MAX_FRAME_LENGTH = 1000
|
|
14
15
|
|
|
16
|
+
# `path:LINE:in 'method'` (colon-glued) or bare `path:LINE`. Verbatim
|
|
17
|
+
# pre-refactor logic, kept byte-identical so upgrading does not fracture
|
|
18
|
+
# existing problem groups — this is the tier-1 (no platform tag) default.
|
|
19
|
+
RUBY_NORMALIZER = ->(frame) {
|
|
20
|
+
frame
|
|
21
|
+
.gsub(/0x[0-9a-fA-F]+/, "0xHEX") # memory addresses
|
|
22
|
+
.gsub(/:[0-9]+(?=:in )/, ":N") # `path:LINE:in 'method'`
|
|
23
|
+
.gsub(/:[0-9]+\z/, ":N") # `path:LINE` (top-level frame, no method)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# `path.cr:LINE in 'method'` or `path.cr:LINE:COL in 'method'` — Crystal
|
|
27
|
+
# separates the line marker from `in` with a space, not a colon, and may
|
|
28
|
+
# carry an optional `:column`, so the Ruby regex is a no-op on it.
|
|
29
|
+
CRYSTAL_NORMALIZER = ->(frame) {
|
|
30
|
+
frame
|
|
31
|
+
.gsub(/0x[0-9a-fA-F]+/, "0xHEX")
|
|
32
|
+
.gsub(/:[0-9]+(?::[0-9]+)?(?= in )/, ":N")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Grammar-agnostic fallback: strips a trailing `:LINE` or `:LINE:COL` only,
|
|
36
|
+
# with no `in`-token assumption. Coarser than a language-specific
|
|
37
|
+
# normalizer (it can't see line noise that isn't at the end of the frame)
|
|
38
|
+
# but never mis-applies a foreign grammar.
|
|
39
|
+
GENERIC_NORMALIZER = ->(frame) {
|
|
40
|
+
frame
|
|
41
|
+
.gsub(/0x[0-9a-fA-F]+/, "0xHEX")
|
|
42
|
+
.gsub(/:[0-9]+(?::[0-9]+)?\z/, ":N")
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
module_function
|
|
16
46
|
|
|
17
|
-
def compute(exception_class:, backtrace:, project:)
|
|
18
|
-
frames = Array(backtrace).first(MAX_FRAMES).map { |frame|
|
|
47
|
+
def compute(exception_class:, backtrace:, project:, normalizer: RUBY_NORMALIZER)
|
|
48
|
+
frames = Array(backtrace).first(MAX_FRAMES).map { |frame| safe_normalize(normalizer, frame.to_s) }
|
|
19
49
|
input = "#{project}\n#{exception_class}\n#{frames.join("\n")}"
|
|
20
50
|
Digest::SHA256.hexdigest(input)[0, 40]
|
|
21
51
|
end
|
|
22
52
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
# Internal helper, not part of the public API. Wraps a (possibly
|
|
54
|
+
# user-registered) normalizer so a broken or pathological callable can
|
|
55
|
+
# never break fingerprinting: the frame is capped before and after
|
|
56
|
+
# normalization, and any exception falls back to the raw capped frame.
|
|
57
|
+
# SystemStackError and NoMemoryError are non-StandardError exceptions a
|
|
58
|
+
# pathological normalizer can realistically raise (e.g. unbounded
|
|
59
|
+
# recursion); a bare `rescue Exception` is deliberately avoided so
|
|
60
|
+
# Interrupt/SystemExit still propagate.
|
|
61
|
+
def safe_normalize(normalizer, frame)
|
|
62
|
+
capped = frame[0, MAX_FRAME_LENGTH].strip
|
|
63
|
+
normalizer.call(capped).to_s[0, MAX_FRAME_LENGTH]
|
|
64
|
+
rescue StandardError, SystemStackError, NoMemoryError
|
|
65
|
+
capped
|
|
32
66
|
end
|
|
33
67
|
end
|
|
34
68
|
end
|
data/lib/sentiero/geo.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
# Deliberately coarse — country/city/region/timezone, no coordinates, no IP:
|
|
5
|
+
# resolution happens in-request and only the result is stored, so
|
|
6
|
+
# config.anonymize_ip is unaffected.
|
|
7
|
+
module Geo
|
|
8
|
+
MAX_VALUE_LENGTH = 256
|
|
9
|
+
PROC_WARNING_LOCK = Mutex.new
|
|
10
|
+
@proc_warning_emitted = false
|
|
11
|
+
|
|
12
|
+
# CF-IPCountry ships with Cloudflare IP geolocation; the other headers need
|
|
13
|
+
# the "Add visitor location headers" managed transform, so country-only is the common case.
|
|
14
|
+
CLOUDFLARE_HEADERS = {
|
|
15
|
+
"geo_country" => "HTTP_CF_IPCOUNTRY",
|
|
16
|
+
"geo_city" => "HTTP_CF_IPCITY",
|
|
17
|
+
"geo_region" => "HTTP_CF_REGION",
|
|
18
|
+
"geo_timezone" => "HTTP_CF_TIMEZONE"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Cloudflare placeholder codes: XX = unknown, T1 = Tor exit.
|
|
22
|
+
CLOUDFLARE_UNKNOWN = %w[XX T1].freeze
|
|
23
|
+
|
|
24
|
+
PROC_KEY_MAP = {
|
|
25
|
+
"country" => "geo_country",
|
|
26
|
+
"city" => "geo_city",
|
|
27
|
+
"region" => "geo_region",
|
|
28
|
+
"timezone" => "geo_timezone"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
TARGET_KEYS = PROC_KEY_MAP.values.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def resolve(env, source, client_metadata = {})
|
|
36
|
+
case source
|
|
37
|
+
when nil then {}
|
|
38
|
+
when :cloudflare then from_cloudflare(env)
|
|
39
|
+
else from_proc(env, source, client_metadata)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def from_cloudflare(env)
|
|
44
|
+
geo = CLOUDFLARE_HEADERS.each_with_object({}) do |(key, header), acc|
|
|
45
|
+
value = clean(env[header])
|
|
46
|
+
acc[key] = value if value
|
|
47
|
+
end
|
|
48
|
+
geo.delete("geo_country") if CLOUDFLARE_UNKNOWN.include?(geo["geo_country"])
|
|
49
|
+
geo
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A broken geo hook must never break ingest: rescue everything, warn once
|
|
53
|
+
# per process, resolve empty. A two-arg resolver opts into the client metadata.
|
|
54
|
+
def from_proc(env, source, client_metadata = {})
|
|
55
|
+
raw = (source.arity == 1) ? source.call(env) : source.call(env, client_metadata)
|
|
56
|
+
return {} unless raw.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
raw.each_with_object({}) do |(key, value), acc|
|
|
59
|
+
mapped = PROC_KEY_MAP[key.to_s]
|
|
60
|
+
value = clean(value)
|
|
61
|
+
acc[mapped] = value if mapped && value
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
PROC_WARNING_LOCK.synchronize do
|
|
65
|
+
return {} if @proc_warning_emitted
|
|
66
|
+
@proc_warning_emitted = true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
warn "[Sentiero] geo_source raised #{e.class}: #{e.message}; skipping geo capture"
|
|
70
|
+
{}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_proc_warning!
|
|
74
|
+
@proc_warning_emitted = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clean(value)
|
|
78
|
+
return nil unless value.is_a?(String)
|
|
79
|
+
|
|
80
|
+
stripped = value.strip
|
|
81
|
+
return nil if stripped.empty?
|
|
82
|
+
|
|
83
|
+
stripped[0, MAX_VALUE_LENGTH]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/sentiero/reporter.rb
CHANGED
|
@@ -145,7 +145,8 @@ module Sentiero
|
|
|
145
145
|
"message" => Redaction.redact_text(exception.message.to_s, config),
|
|
146
146
|
"backtrace" => Array(exception.backtrace).map { |frame| Redaction.redact_text(frame.to_s, config) },
|
|
147
147
|
"context" => Redaction.deep_redact_strings(scrubber.scrub(report_ctx.metadata), config),
|
|
148
|
-
"timestamp" => Time.now.to_f
|
|
148
|
+
"timestamp" => Time.now.to_f,
|
|
149
|
+
"platform" => "ruby"
|
|
149
150
|
}
|
|
150
151
|
payload["session_id"] = report_ctx.session_id if report_ctx.session_id
|
|
151
152
|
payload["window_id"] = report_ctx.window_id if report_ctx.window_id
|
|
@@ -12,7 +12,12 @@ module Sentiero
|
|
|
12
12
|
raise NoMethodError, "#{self.class}#save_events not implemented"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# min_duration_ms filters on the event-timestamp span (last_event_at -
|
|
16
|
+
# first_event_at, milliseconds); sessions without event timestamps fail
|
|
17
|
+
# an active duration filter. Both thresholds are inclusive and apply
|
|
18
|
+
# before limit/offset.
|
|
19
|
+
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil,
|
|
20
|
+
min_duration_ms: nil, min_events: nil)
|
|
16
21
|
raise NoMethodError, "#{self.class}#list_sessions not implemented"
|
|
17
22
|
end
|
|
18
23
|
|
|
@@ -40,8 +45,16 @@ module Sentiero
|
|
|
40
45
|
# Yields [session_summary_hash, window_id, events_array] per window, newest
|
|
41
46
|
# sessions first, capped at `limit`. Built from list_sessions/get_session/
|
|
42
47
|
# get_events so every backend gets it free; stores may override.
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
#
|
|
49
|
+
# `types:` (integer rrweb event types) narrows only the yielded events
|
|
50
|
+
# arrays. Summaries and the yielded window set always describe the whole
|
|
51
|
+
# session, and carry :window_starts ({window_id => first event
|
|
52
|
+
# timestamp}) so replay offsets stay anchored when a window's first
|
|
53
|
+
# event is filtered out.
|
|
54
|
+
def each_session_events(limit: nil, since: nil, until_time: nil, types: nil)
|
|
55
|
+
unless block_given?
|
|
56
|
+
return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time, types: types)
|
|
57
|
+
end
|
|
45
58
|
|
|
46
59
|
cap = limit || limits.analytics_max_scan_sessions
|
|
47
60
|
sessions = list_sessions(limit: cap, since: since, until_time: until_time)
|
|
@@ -51,14 +64,49 @@ module Sentiero
|
|
|
51
64
|
next unless session
|
|
52
65
|
|
|
53
66
|
windows = session[:windows] || []
|
|
67
|
+
summary = summary.merge(
|
|
68
|
+
window_starts: windows.to_h { |window| [window[:window_id], window[:first_event_at]] }
|
|
69
|
+
)
|
|
54
70
|
windows.each do |window|
|
|
55
71
|
window_id = window[:window_id]
|
|
56
72
|
events = get_events(WindowRef.new(summary[:session_id], window_id))
|
|
73
|
+
events = events.select { |event| types.include?(event["type"]) } if types
|
|
57
74
|
yield summary, window_id, events
|
|
58
75
|
end
|
|
59
76
|
end
|
|
60
77
|
end
|
|
61
78
|
|
|
79
|
+
# {integer_type => count}; non-integer client "type" values tally under
|
|
80
|
+
# nil. SQLite overrides with a payload-free aggregate.
|
|
81
|
+
def event_type_counts(limit: nil, since: nil, until_time: nil)
|
|
82
|
+
counts = {}
|
|
83
|
+
each_session_events(limit: limit, since: since, until_time: until_time) do |_summary, _window_id, events|
|
|
84
|
+
events.each do |event|
|
|
85
|
+
type = event["type"]
|
|
86
|
+
key = type.is_a?(Integer) ? type : nil
|
|
87
|
+
counts[key] = (counts[key] || 0) + 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
counts
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# {"YYYY-MM-DD" (UTC day of the ms timestamp) => count}; events without
|
|
94
|
+
# a numeric timestamp are skipped. SQLite overrides with a payload-free
|
|
95
|
+
# aggregate.
|
|
96
|
+
def event_counts_by_day(limit: nil, since: nil, until_time: nil)
|
|
97
|
+
counts = {}
|
|
98
|
+
each_session_events(limit: limit, since: since, until_time: until_time) do |_summary, _window_id, events|
|
|
99
|
+
events.each do |event|
|
|
100
|
+
timestamp = event["timestamp"]
|
|
101
|
+
next unless timestamp.is_a?(Numeric)
|
|
102
|
+
|
|
103
|
+
day = Time.at(timestamp / 1000.0).utc.to_date.to_s
|
|
104
|
+
counts[day] = (counts[day] || 0) + 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
counts
|
|
108
|
+
end
|
|
109
|
+
|
|
62
110
|
# Deletes every session whose updated_at is older than `seconds` ago,
|
|
63
111
|
# returning the count. Built from list_sessions + delete_session so every
|
|
64
112
|
# backend gets it free; stores may override with a direct query.
|
|
@@ -106,6 +154,17 @@ module Sentiero
|
|
|
106
154
|
entry
|
|
107
155
|
end
|
|
108
156
|
|
|
157
|
+
def session_meets_thresholds?(summary, min_duration_ms, min_events)
|
|
158
|
+
return false if min_events && summary[:event_count] < min_events
|
|
159
|
+
|
|
160
|
+
if min_duration_ms
|
|
161
|
+
first = summary[:first_event_at]
|
|
162
|
+
last = summary[:last_event_at]
|
|
163
|
+
return false unless first && last && (last - first) >= min_duration_ms
|
|
164
|
+
end
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
109
168
|
# True when the search term (case-insensitive) appears in the session_id
|
|
110
169
|
# or any metadata value.
|
|
111
170
|
def session_matches_search?(summary, search)
|
data/lib/sentiero/store.rb
CHANGED
|
@@ -28,6 +28,13 @@ module Sentiero
|
|
|
28
28
|
@limits ||= Limits.new
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# True when event_type_counts / event_counts_by_day avoid reading event
|
|
32
|
+
# payloads (native aggregates). Via the scan-based fallbacks each figure
|
|
33
|
+
# would re-scan the store, so StatsAggregator only calls them when true.
|
|
34
|
+
def supports_event_aggregates?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
private
|
|
32
39
|
|
|
33
40
|
def validate_id!(id)
|
data/lib/sentiero/stores/file.rb
CHANGED
|
@@ -60,7 +60,8 @@ module Sentiero
|
|
|
60
60
|
nil
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil
|
|
63
|
+
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil,
|
|
64
|
+
min_duration_ms: nil, min_events: nil)
|
|
64
65
|
session_ids = list_session_ids
|
|
65
66
|
return [] if session_ids.empty?
|
|
66
67
|
|
|
@@ -77,6 +78,9 @@ module Sentiero
|
|
|
77
78
|
if search && !search.empty?
|
|
78
79
|
summaries = summaries.select { |summary| session_matches_search?(summary, search) }
|
|
79
80
|
end
|
|
81
|
+
if min_duration_ms || min_events
|
|
82
|
+
summaries = summaries.select { |summary| session_meets_thresholds?(summary, min_duration_ms, min_events) }
|
|
83
|
+
end
|
|
80
84
|
|
|
81
85
|
case sort_by
|
|
82
86
|
when "created_at"
|
|
@@ -64,9 +64,11 @@ module Sentiero
|
|
|
64
64
|
nil
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil
|
|
67
|
+
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil,
|
|
68
|
+
min_duration_ms: nil, min_events: nil)
|
|
68
69
|
pairs = @sessions.each_pair.to_a
|
|
69
|
-
pairs = filter_sessions(pairs, since: since, until_time: until_time, search: search
|
|
70
|
+
pairs = filter_sessions(pairs, since: since, until_time: until_time, search: search,
|
|
71
|
+
min_duration_ms: min_duration_ms, min_events: min_events)
|
|
70
72
|
pairs = sort_sessions(pairs, sort_by)
|
|
71
73
|
page = pairs.slice(offset, limit) || []
|
|
72
74
|
page.map { |sid, entry| session_summary(sid, entry) }
|
|
@@ -274,7 +276,7 @@ module Sentiero
|
|
|
274
276
|
|
|
275
277
|
private
|
|
276
278
|
|
|
277
|
-
def filter_sessions(pairs, since:, until_time:, search:)
|
|
279
|
+
def filter_sessions(pairs, since:, until_time:, search:, min_duration_ms: nil, min_events: nil)
|
|
278
280
|
if since
|
|
279
281
|
since_f = since.to_f
|
|
280
282
|
pairs = pairs.select { |_sid, entry| entry.meta.updated_at >= since_f }
|
|
@@ -290,6 +292,16 @@ module Sentiero
|
|
|
290
292
|
entry.meta.session_metadata&.values&.any? { |value| value.to_s.downcase.include?(search_down) }
|
|
291
293
|
}
|
|
292
294
|
end
|
|
295
|
+
if min_events
|
|
296
|
+
pairs = pairs.select { |_sid, entry| entry.windows.values.sum(&:size) >= min_events }
|
|
297
|
+
end
|
|
298
|
+
if min_duration_ms
|
|
299
|
+
pairs = pairs.select { |_sid, entry|
|
|
300
|
+
first = entry.meta.first_event_at
|
|
301
|
+
last = entry.meta.last_event_at
|
|
302
|
+
first && last && (last - first) >= min_duration_ms
|
|
303
|
+
}
|
|
304
|
+
end
|
|
293
305
|
pairs
|
|
294
306
|
end
|
|
295
307
|
|
|
@@ -76,8 +76,10 @@ module Sentiero
|
|
|
76
76
|
|
|
77
77
|
# Batched scan: three pipelined round-trips total instead of the base's
|
|
78
78
|
# get_session + get_events per window.
|
|
79
|
-
def each_session_events(limit: nil, since: nil, until_time: nil)
|
|
80
|
-
|
|
79
|
+
def each_session_events(limit: nil, since: nil, until_time: nil, types: nil)
|
|
80
|
+
unless block_given?
|
|
81
|
+
return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time, types: types)
|
|
82
|
+
end
|
|
81
83
|
|
|
82
84
|
cap = limit || limits.analytics_max_scan_sessions
|
|
83
85
|
min = since ? since.to_f.to_s : "-inf"
|
|
@@ -114,7 +116,13 @@ module Sentiero
|
|
|
114
116
|
|
|
115
117
|
windows = window_ids.to_h { |wid| [wid, parse_events(event_futures[[sid, wid]].value)] }
|
|
116
118
|
summary = scan_summary(sid, meta, window_ids, windows.values.sum(&:size))
|
|
117
|
-
|
|
119
|
+
summary = summary.merge(
|
|
120
|
+
window_starts: windows.transform_values { |events| events.first&.fetch("timestamp", nil) }
|
|
121
|
+
)
|
|
122
|
+
windows.each do |wid, events|
|
|
123
|
+
events = events.select { |event| types.include?(event["type"]) } if types
|
|
124
|
+
yield summary, wid, events
|
|
125
|
+
end
|
|
118
126
|
end
|
|
119
127
|
end
|
|
120
128
|
|
|
@@ -122,11 +130,12 @@ module Sentiero
|
|
|
122
130
|
# ZREVRANGEBYSCORE with LIMIT pages the score-ordered sessions zset
|
|
123
131
|
# directly. created_at/event_count sort or search need every matching
|
|
124
132
|
# session's summary first, so those go through the general path below.
|
|
125
|
-
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil
|
|
133
|
+
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil,
|
|
134
|
+
min_duration_ms: nil, min_events: nil)
|
|
126
135
|
min = since ? since.to_f.to_s : "-inf"
|
|
127
136
|
max = until_time ? until_time.to_f.to_s : "+inf"
|
|
128
137
|
|
|
129
|
-
if default_sort?(sort_by) && (search.nil? || search.empty?)
|
|
138
|
+
if default_sort?(sort_by) && (search.nil? || search.empty?) && min_duration_ms.nil? && min_events.nil?
|
|
130
139
|
session_ids = @redis.zrevrangebyscore(@keys.sessions, max, min, limit: [offset, limit])
|
|
131
140
|
return build_session_summaries(session_ids)
|
|
132
141
|
end
|
|
@@ -137,6 +146,9 @@ module Sentiero
|
|
|
137
146
|
summaries = build_session_summaries(session_ids)
|
|
138
147
|
|
|
139
148
|
summaries.select! { |summary| session_matches_search?(summary, search) } if search && !search.empty?
|
|
149
|
+
if min_duration_ms || min_events
|
|
150
|
+
summaries.select! { |summary| session_meets_thresholds?(summary, min_duration_ms, min_events) }
|
|
151
|
+
end
|
|
140
152
|
|
|
141
153
|
case sort_by
|
|
142
154
|
when "created_at"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Sentiero
|
|
6
|
+
module Stores
|
|
7
|
+
class SQLite
|
|
8
|
+
# zlib compression for events.data payloads. FullSnapshots dominate the
|
|
9
|
+
# table's bytes (~93% in production samples) and deflate ~6-7x, so
|
|
10
|
+
# payloads at or above the threshold are stored as compressed BLOBs;
|
|
11
|
+
# smaller rows stay plain JSON TEXT (near-zero win, and skipping them
|
|
12
|
+
# keeps ~85% of rows out of the deflate path). Reads sniff the storage
|
|
13
|
+
# format from the payload itself: a zlib stream starts with 0x78 and a
|
|
14
|
+
# valid header checksum, which no JSON text can.
|
|
15
|
+
module PayloadCodec
|
|
16
|
+
COMPRESSION_THRESHOLD = 1024
|
|
17
|
+
# CMF byte for deflate with a 32K window — the only one zlib emits.
|
|
18
|
+
ZLIB_FIRST_BYTE = 0x78
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def encode(json)
|
|
23
|
+
return json if json.bytesize < COMPRESSION_THRESHOLD
|
|
24
|
+
|
|
25
|
+
::SQLite3::Blob.new(Zlib::Deflate.deflate(json))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def decode(data)
|
|
29
|
+
return data unless compressed?(data)
|
|
30
|
+
|
|
31
|
+
Zlib::Inflate.inflate(data).force_encoding(Encoding::UTF_8)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def compressed?(data)
|
|
35
|
+
data.getbyte(0) == ZLIB_FIRST_BYTE &&
|
|
36
|
+
(flg = data.getbyte(1)) &&
|
|
37
|
+
(((ZLIB_FIRST_BYTE << 8) | flg) % 31).zero?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -3,8 +3,21 @@
|
|
|
3
3
|
module Sentiero
|
|
4
4
|
module Stores
|
|
5
5
|
class SQLite
|
|
6
|
-
# Table/index definitions for the store's
|
|
6
|
+
# Table/index definitions for the store's SQLite connections.
|
|
7
7
|
module Schema
|
|
8
|
+
# The events.type column (denormalized rrweb event type) sits BEFORE
|
|
9
|
+
# the data blob on purpose: SQLite reads record columns left to right,
|
|
10
|
+
# so a typed scan can check `type` without ever touching the (often
|
|
11
|
+
# multi-page overflow) data payload of non-matching rows.
|
|
12
|
+
EVENTS_COLUMNS = <<~SQL
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
session_id TEXT NOT NULL,
|
|
15
|
+
window_id TEXT NOT NULL,
|
|
16
|
+
timestamp REAL,
|
|
17
|
+
type INTEGER,
|
|
18
|
+
data TEXT NOT NULL
|
|
19
|
+
SQL
|
|
20
|
+
|
|
8
21
|
def self.create(db)
|
|
9
22
|
db.execute_batch(<<~SQL)
|
|
10
23
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -21,11 +34,7 @@ module Sentiero
|
|
|
21
34
|
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
|
|
22
35
|
|
|
23
36
|
CREATE TABLE IF NOT EXISTS events (
|
|
24
|
-
|
|
25
|
-
session_id TEXT NOT NULL,
|
|
26
|
-
window_id TEXT NOT NULL,
|
|
27
|
-
timestamp REAL,
|
|
28
|
-
data TEXT NOT NULL
|
|
37
|
+
#{EVENTS_COLUMNS}
|
|
29
38
|
);
|
|
30
39
|
|
|
31
40
|
CREATE INDEX IF NOT EXISTS idx_events_session_window_ts ON events(session_id, window_id, timestamp);
|
|
@@ -72,6 +81,63 @@ module Sentiero
|
|
|
72
81
|
CREATE INDEX IF NOT EXISTS idx_server_events_project ON server_events(project);
|
|
73
82
|
CREATE INDEX IF NOT EXISTS idx_server_events_session ON server_events(session_id);
|
|
74
83
|
SQL
|
|
84
|
+
|
|
85
|
+
migrate_events_type!(db)
|
|
86
|
+
migrate_compress_payloads!(db)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Databases created before the events.type column existed get a
|
|
90
|
+
# one-time table rebuild (not ALTER ADD COLUMN — that would append
|
|
91
|
+
# type AFTER the data blob, defeating the column-order optimization
|
|
92
|
+
# above). Backfills type from each row's JSON payload.
|
|
93
|
+
def self.migrate_events_type!(db)
|
|
94
|
+
columns = db.execute("PRAGMA table_info(events)").map { |row| row["name"] }
|
|
95
|
+
return if columns.include?("type")
|
|
96
|
+
|
|
97
|
+
db.transaction(:immediate) do
|
|
98
|
+
db.execute_batch(<<~SQL)
|
|
99
|
+
CREATE TABLE events_migrated (
|
|
100
|
+
#{EVENTS_COLUMNS}
|
|
101
|
+
);
|
|
102
|
+
INSERT INTO events_migrated (id, session_id, window_id, timestamp, type, data)
|
|
103
|
+
SELECT id, session_id, window_id, timestamp,
|
|
104
|
+
CASE WHEN json_type(data, '$.type') = 'integer'
|
|
105
|
+
THEN json_extract(data, '$.type') END,
|
|
106
|
+
data
|
|
107
|
+
FROM events;
|
|
108
|
+
DROP TABLE events;
|
|
109
|
+
ALTER TABLE events_migrated RENAME TO events;
|
|
110
|
+
CREATE INDEX idx_events_session_window_ts ON events(session_id, window_id, timestamp);
|
|
111
|
+
SQL
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# user_version 1 = every large payload is zlib-compressed (PayloadCodec).
|
|
116
|
+
SCHEMA_VERSION = 1
|
|
117
|
+
|
|
118
|
+
# One-time pass compressing pre-existing plain-TEXT payloads. Batched
|
|
119
|
+
# in short transactions so a live recorder's ingest writes interleave
|
|
120
|
+
# instead of timing out behind one giant lock. Guarded by
|
|
121
|
+
# PRAGMA user_version, so subsequent opens skip the table scan.
|
|
122
|
+
# Frees pages for reuse but does not shrink the file — VACUUM manually
|
|
123
|
+
# to reclaim disk.
|
|
124
|
+
def self.migrate_compress_payloads!(db)
|
|
125
|
+
return if db.get_first_value("PRAGMA user_version").to_i >= SCHEMA_VERSION
|
|
126
|
+
|
|
127
|
+
loop do
|
|
128
|
+
rows = db.execute(
|
|
129
|
+
"SELECT id, data FROM events WHERE typeof(data) = 'text' AND LENGTH(data) >= ? LIMIT 500",
|
|
130
|
+
[PayloadCodec::COMPRESSION_THRESHOLD]
|
|
131
|
+
)
|
|
132
|
+
break if rows.empty?
|
|
133
|
+
|
|
134
|
+
db.transaction(:immediate) do
|
|
135
|
+
rows.each do |row|
|
|
136
|
+
db.execute("UPDATE events SET data = ? WHERE id = ?", [PayloadCodec.encode(row["data"]), row["id"]])
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
db.execute("PRAGMA user_version = #{SCHEMA_VERSION}")
|
|
75
141
|
end
|
|
76
142
|
end
|
|
77
143
|
end
|