sentiero 1.0.0.alpha1 → 1.0.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -13
  3. data/lib/sentiero/analytics/analyzer.rb +16 -4
  4. data/lib/sentiero/analytics/browser_event_discovery.rb +2 -2
  5. data/lib/sentiero/analytics/conversion_analyzer.rb +1 -1
  6. data/lib/sentiero/analytics/engagement_analyzer.rb +1 -1
  7. data/lib/sentiero/analytics/error_discovery.rb +9 -2
  8. data/lib/sentiero/analytics/exporter.rb +1 -1
  9. data/lib/sentiero/analytics/frustration_analyzer.rb +1 -1
  10. data/lib/sentiero/analytics/funnel_analyzer.rb +2 -2
  11. data/lib/sentiero/analytics/heatmap_analyzer.rb +3 -3
  12. data/lib/sentiero/analytics/page_report_analyzer.rb +2 -2
  13. data/lib/sentiero/analytics/scroll_depth_analyzer.rb +1 -1
  14. data/lib/sentiero/analytics/segmenter.rb +16 -2
  15. data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +2 -0
  16. data/lib/sentiero/analytics/stats_aggregator.rb +45 -6
  17. data/lib/sentiero/analytics/web_vitals_analyzer.rb +2 -2
  18. data/lib/sentiero/configuration.rb +29 -0
  19. data/lib/sentiero/fingerprint/config.rb +40 -0
  20. data/lib/sentiero/fingerprint.rb +45 -11
  21. data/lib/sentiero/geo.rb +86 -0
  22. data/lib/sentiero/reporter.rb +2 -1
  23. data/lib/sentiero/store/session_store.rb +62 -3
  24. data/lib/sentiero/store.rb +7 -0
  25. data/lib/sentiero/stores/file.rb +5 -1
  26. data/lib/sentiero/stores/memory.rb +15 -3
  27. data/lib/sentiero/stores/redis.rb +17 -5
  28. data/lib/sentiero/stores/sqlite/payload_codec.rb +42 -0
  29. data/lib/sentiero/stores/sqlite/schema.rb +72 -6
  30. data/lib/sentiero/stores/sqlite.rb +216 -101
  31. data/lib/sentiero/version.rb +1 -1
  32. data/lib/sentiero/web/analytics_app.rb +10 -0
  33. data/lib/sentiero/web/assets/dashboard-YINQE5ZH.js +3 -0
  34. data/lib/sentiero/web/assets/manifest.json +4 -4
  35. data/lib/sentiero/web/assets/recorder-W2RCU5VM.js +78 -0
  36. data/lib/sentiero/web/assets/rrweb-player-d522d74c.js +201 -0
  37. data/lib/sentiero/web/assets/{style-d71e72fd.css → style-80e536e6.css} +1 -1
  38. data/lib/sentiero/web/body_reader.rb +21 -10
  39. data/lib/sentiero/web/dashboard_app.rb +77 -5
  40. data/lib/sentiero/web/errors_app.rb +14 -1
  41. data/lib/sentiero/web/events_app.rb +6 -1
  42. data/lib/sentiero/web/ingest_app.rb +1 -1
  43. data/lib/sentiero/web/templates/client_error_show.html.erb +6 -1
  44. data/lib/sentiero/web/templates/dashboard.html.erb +7 -0
  45. data/lib/sentiero/web/templates/maintenance.html.erb +50 -0
  46. data/lib/sentiero/web/templates/segments.html.erb +9 -0
  47. data/lib/sentiero/web/templates/sessions_index.html.erb +12 -4
  48. data/lib/sentiero/web/views/analytics_index_view.rb +7 -1
  49. data/lib/sentiero/web/views/client_error_show_view.rb +1 -1
  50. data/lib/sentiero/web/views/maintenance_view.rb +27 -0
  51. data/lib/sentiero/web/views/segments_view.rb +3 -2
  52. data/lib/sentiero/web/views/sessions_index_view.rb +6 -2
  53. data/lib/sentiero/web/views.rb +1 -0
  54. data/lib/sentiero.rb +1 -0
  55. metadata +10 -5
  56. data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +0 -3
  57. data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +0 -71
  58. data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +0 -126
@@ -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
@@ -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| normalize_frame(frame.to_s) }
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
- # Strips per-occurrence noise (memory addresses, line numbers). Digits inside
24
- # identifiers (e.g. `step_1`, `V2::Api`) are preserved so distinct methods do
25
- # not collapse into one group.
26
- def normalize_frame(frame)
27
- frame = frame[0, MAX_FRAME_LENGTH].strip
28
- frame
29
- .gsub(/0x[0-9a-fA-F]+/, "0xHEX") # memory addresses
30
- .gsub(/:[0-9]+(?=:in )/, ":N") # `path:LINE:in 'method'`
31
- .gsub(/:[0-9]+\z/, ":N") # `path:LINE` (top-level frame, no method)
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
@@ -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
@@ -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
- def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
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
- def each_session_events(limit: nil, since: nil, until_time: nil)
44
- return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time) unless block_given?
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)
@@ -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)
@@ -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
- return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time) unless block_given?
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
- windows.each { |wid, events| yield summary, wid, events }
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 single SQLite3 connection.
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
- 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
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