sentiero 1.0.0.alpha1 → 1.0.0.beta1

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 (55) 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 +2 -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/schema.rb +43 -6
  29. data/lib/sentiero/stores/sqlite.rb +214 -100
  30. data/lib/sentiero/version.rb +1 -1
  31. data/lib/sentiero/web/analytics_app.rb +10 -0
  32. data/lib/sentiero/web/assets/dashboard-YINQE5ZH.js +3 -0
  33. data/lib/sentiero/web/assets/manifest.json +4 -4
  34. data/lib/sentiero/web/assets/recorder-W2RCU5VM.js +78 -0
  35. data/lib/sentiero/web/assets/rrweb-player-d522d74c.js +201 -0
  36. data/lib/sentiero/web/assets/{style-d71e72fd.css → style-80e536e6.css} +1 -1
  37. data/lib/sentiero/web/body_reader.rb +21 -10
  38. data/lib/sentiero/web/dashboard_app.rb +77 -5
  39. data/lib/sentiero/web/errors_app.rb +14 -1
  40. data/lib/sentiero/web/events_app.rb +6 -1
  41. data/lib/sentiero/web/ingest_app.rb +1 -1
  42. data/lib/sentiero/web/templates/dashboard.html.erb +7 -0
  43. data/lib/sentiero/web/templates/maintenance.html.erb +50 -0
  44. data/lib/sentiero/web/templates/segments.html.erb +9 -0
  45. data/lib/sentiero/web/templates/sessions_index.html.erb +12 -4
  46. data/lib/sentiero/web/views/analytics_index_view.rb +7 -1
  47. data/lib/sentiero/web/views/maintenance_view.rb +27 -0
  48. data/lib/sentiero/web/views/segments_view.rb +3 -2
  49. data/lib/sentiero/web/views/sessions_index_view.rb +6 -2
  50. data/lib/sentiero/web/views.rb +1 -0
  51. data/lib/sentiero.rb +1 -0
  52. metadata +9 -5
  53. data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +0 -3
  54. data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +0 -71
  55. data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +0 -126
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d366ebafb881e12dd1d59ac702c282f6a82b7a841842c48243f1ac0a4d18e0be
4
- data.tar.gz: 52229be75a9458f841898ab00dd53d8e5c3016c6b4d80efd35946ad649b2e24b
3
+ metadata.gz: c8bc35b365449e9111ed5dcc093ea7fceaf60d739b47e1bf13df8f7cbc83cf55
4
+ data.tar.gz: 39c9a76c8acf2f22d44fcc3fa88d450ef1e36a368434ba5ae143c82bd2800b30
5
5
  SHA512:
6
- metadata.gz: 33cdbcd859bf98e7baf6c38f63bbf52706b6c0d6c42f9af4ff9b2fa50a60f91e1705a535ac06d7b7a0d607f640cefab532008ba634609076127292bc27b0cebc
7
- data.tar.gz: 337f37bf15a0c89e343f9908b1c5cd69c986abd2e184521fc7be827f0c71dc4f6df1c0bd7885caa687191f095bb6636a1a1b175d774403676378ab594b6d7d49
6
+ metadata.gz: 527083a0dc823bb03343f97b41f3f40e3c629ce201dbf234613c9d615068e8e481d612f0aa0ff8a5736e6cc487f1f6d049f0d704647b0fa37e7361d4cafa4003
7
+ data.tar.gz: c77ebaff3d74da9cdef241db684ccb6f0b5a141a10932fd49d3af131bd4434ddb8194ee78abab087a16893c2b01a4566f0f75f98f44e17679e5107bfb2a9b0dc
data/README.md CHANGED
@@ -19,13 +19,15 @@ Captures user interactions via [rrweb](https://www.rrweb.io/), stores them serve
19
19
 
20
20
  Similar to [SpectatorSport](https://github.com/bensheldon/spectator_sport) by Ben Sheldon but not bound to Rails.
21
21
 
22
- 📖 **Full documentation: [sentiero.app](https://sentiero.app)**
22
+ **Full documentation: [sentiero.app](https://www.sentiero.app)**
23
+
24
+ **Demo: [demo.sentiero.app](https://demo.sentiero.app)** (login to dashboard with `demo`/`demo`)
23
25
 
24
26
  ### Why Sentiero?
25
27
 
26
28
  - **De-SaaS your session recording** — keep user interaction data in your own infrastructure instead of sending it to third-party services
27
29
  - **Privacy-respecting defaults** — all inputs masked by default, password masking enforced and cannot be disabled, per-element control via HTML attributes
28
- - **User-side controls** — respects Do Not Track (DNT) and Global Privacy Control (GPC), with support for explicit user opt-in/opt-out
30
+ - **User-side controls** — honors Global Privacy Control (GPC) out of the box, with support for explicit user opt-in/opt-out
29
31
  - **Framework-agnostic** — drop into any Rack-compatible app, or use the dedicated Rails integration
30
32
  - **Complete but focused** — session recording, replay, and the tools around them, without trying to be an analytics platform
31
33
 
@@ -43,7 +45,7 @@ Also:
43
45
  - Session metadata, captures URL, browser, viewport, referrer (opt-in)
44
46
  - Navigation tracking, automatic outbound link logging (opt-in)
45
47
  - Error capture, JS errors recorded in the timeline (opt-in)
46
- - Custom events, imperative JS API or declarative `data-sentiero-track-*` HTML attributes ([docs](https://sentiero.app/guide/custom-events/))
48
+ - Custom events, imperative JS API or declarative `data-sentiero-track-*` HTML attributes ([docs](https://www.sentiero.app/guide/custom-events/))
47
49
  - JSON export and shareable deep-links with timestamp
48
50
  - Replay enhancers, click overlay, scroll-depth indicator, frustration annotations (rage/dead clicks), form-interaction detail in the activity sidebar, Web Vitals badges, and a has-errors session filter
49
51
  - Cross-session [Analytics](#analytics), pages, segments, errors, heatmaps, scroll, and forms across all recorded sessions
@@ -139,7 +141,7 @@ Include in your HTML layout (before `</body>`):
139
141
 
140
142
  That's it. Sessions are now being recorded and viewable at `/sentiero/`.
141
143
 
142
- > **Using Rails?** The `sentiero-rails` gem adds ActiveRecord storage, a migration generator, and view helpers. See [the Rails guide](https://sentiero.app/guide/rails/) for the full guide.
144
+ > **Using Rails?** The `sentiero-rails` gem adds ActiveRecord storage, a migration generator, and view helpers. See [the Rails guide](https://www.sentiero.app/guide/rails/) for the full guide.
143
145
 
144
146
  > **Going to production?** Read the [Production Checklist](#production-checklist) first.
145
147
 
@@ -178,7 +180,7 @@ Use route-level auth when you need `401`/`302` responses (e.g., HTTP Basic promp
178
180
 
179
181
  The events endpoint (`EventsApp`) is intentionally public, it receives browser-generated rrweb data. Protect it with CORS (`cors_origins`), rate limiting, and payload size limits instead.
180
182
 
181
- See [the authentication guide](https://sentiero.app/guide/authentication/) for the full guide including Rails, Sinatra, and plain Rack examples.
183
+ See [the authentication guide](https://www.sentiero.app/guide/authentication/) for the full guide including Rails, Sinatra, and plain Rack examples.
182
184
 
183
185
  ## Privacy
184
186
 
@@ -247,10 +249,10 @@ Sentiero ships a compliance toolkit for GDPR/CCPA-style obligations:
247
249
  - **Server-side redaction** — the `config.redaction` engine scrubs events on ingest before they reach the store: builtin patterns (emails, tokens, cards), URL query handling (`url_mode`, allow/denylists), `custom_patterns` for server-rendered PII you can pattern-match, and a `server_proc` hook (the ingest-side backstop to `data-rr-mask`/`data-rr-block`). Redaction is fail-closed: an error in `server_proc` drops the batch rather than persisting unsanitized data.
248
250
  - **IP anonymization** — `config.anonymize_ip` (default `true`) truncates client IPs before storage; set to `false` to keep raw IPs.
249
251
  - **Data retention / purge** — set `config.retention_period` (seconds) and call `Sentiero.purge_expired!` from a scheduler, or run `rake sentiero:purge` in Rails apps.
250
- - **Right to erasure** — `Sentiero.erase_sessions(*ids)` / `Sentiero.erase_where(**filters)`, or `rake sentiero:erase` in Rails apps.
252
+ - **Right to erasure** — `Sentiero.erase_sessions(ids)` / `Sentiero.erase_where(**filters)`, or `rake sentiero:erase` in Rails apps.
251
253
  - **Audit hook** — `config.audit_log` receives compliance-relevant events (opt-outs, erasures, purges) for your own logging.
252
254
 
253
- See [the privacy guide](https://sentiero.app/guide/privacy/) for the full privacy guide including cross-tab sessions, global recording options, and compliance details.
255
+ See [the privacy guide](https://www.sentiero.app/guide/privacy/) for the full privacy guide including cross-tab sessions, global recording options, and compliance details, and [Implementing Consent & Opt-Out](https://www.sentiero.app/guide/consent/) for step-by-step consent-banner, opt-out-toggle, and right-to-erasure recipes.
254
256
 
255
257
  ## Analytics
256
258
 
@@ -270,7 +272,7 @@ Analytics are **compute-on-read**: there are no rollup tables, the analyzers que
270
272
 
271
273
  DashboardApp serves these routes automatically. To mount the analytics UI on its own, use the Roda helper `r.sentiero_analytics`.
272
274
 
273
- See [the analytics guide](https://sentiero.app/guide/analytics/) for details.
275
+ See [the analytics guide](https://www.sentiero.app/guide/analytics/) for details.
274
276
 
275
277
  ## Shareable Replays
276
278
 
@@ -283,7 +285,7 @@ Both are gated by `config.shareable_replays` (default `false`); while disabled,
283
285
 
284
286
  > **Security:** a share file is a full session dump that leaves your infrastructure. Treat it like any other export of recorded data and only enable sharing if that's acceptable for your privacy posture.
285
287
 
286
- See [the sharing guide](https://sentiero.app/guide/sharing/) for details.
288
+ See [the sharing guide](https://www.sentiero.app/guide/sharing/) for details.
287
289
 
288
290
  ## Server-side Error Tracking
289
291
 
@@ -400,7 +402,7 @@ captures and re-raises unhandled exceptions, and reads the session/window
400
402
  cookies into context). Opt out with `Sentiero::Rails.configure { |c| c.reporter_middleware = false }`.
401
403
  For non-Rails apps, add the middleware yourself: `use Sentiero::Reporter::Middleware`.
402
404
 
403
- See [the error tracking guide](https://sentiero.app/guide/error-tracking/) for the full guide
405
+ See [the error tracking guide](https://www.sentiero.app/guide/error-tracking/) for the full guide
404
406
  (architecture, the `/issues` and `/custom-events` dashboards, deployment, and
405
407
  the Crystal shard).
406
408
 
@@ -476,7 +478,7 @@ end
476
478
  | `capture_metadata` | `Boolean` | `false` | Capture page URL, browser, viewport, and referrer per session |
477
479
  | `capture_errors` | `Boolean` | `false` | Capture JS errors and unhandled promise rejections as timeline events |
478
480
  | `track_navigation` | `Boolean` | `false` | Automatically log outbound link clicks as custom events |
479
- | `track_custom_events` | `Boolean` | `false` | Enable declarative `data-sentiero-track-*` HTML attributes for custom events ([docs](https://sentiero.app/guide/custom-events/)) |
481
+ | `track_custom_events` | `Boolean` | `false` | Enable declarative `data-sentiero-track-*` HTML attributes for custom events ([docs](https://www.sentiero.app/guide/custom-events/)) |
480
482
  | `track_forms` | `Boolean` | `false` | Capture real form submits as `__form_submit` events for form analytics (form `name`/`id` attributes + page URL — never values) |
481
483
  | `mask_all_inputs` | `Boolean` | `true` | Mask all form input values in recordings |
482
484
  | `mask_input_options` | `Hash` | `{}` | Per-input-type masking; `password: true` is always enforced |
@@ -600,7 +602,7 @@ Requires the `redis` gem.
600
602
 
601
603
  Implement the methods defined in `Sentiero::Store` and verify with the shared contract tests.
602
604
 
603
- See [the storage guide](https://sentiero.app/guide/storage/) for details on all backends, Redis data structures, and building custom stores.
605
+ See [the storage guide](https://www.sentiero.app/guide/storage/) for details on all backends, Redis data structures, and building custom stores.
604
606
 
605
607
  ## Security
606
608
 
@@ -646,7 +648,7 @@ cd frontend && npm install && npm run build
646
648
  # Output: lib/sentiero/web/assets/vendor/recorder.js
647
649
  ```
648
650
 
649
- See [the recorder guide](https://sentiero.app/guide/recorder/) for details on the frontend recorder's batching, compression, and retry behavior.
651
+ See [the recorder guide](https://www.sentiero.app/guide/recorder/) for details on the frontend recorder's batching, compression, and retry behavior.
650
652
 
651
653
  ### Testing
652
654
 
@@ -27,10 +27,14 @@ module Sentiero
27
27
  # [sessions_scanned, hit_cap]. Counts DISTINCT sessions (not windows), so
28
28
  # `hit_cap` is correct even when a session spans several windows. Callers
29
29
  # build was_truncated as `collector.capped || hit_cap`.
30
- def scan_sessions(limit: nil, since: nil, until_time: nil)
30
+ # types: lets the store skip fetching/parsing event types the analyzer
31
+ # never reads (above all type-2 DOM snapshots, which dominate stored
32
+ # bytes). Always include META — page segmentation and entry attribution
33
+ # anchor on it.
34
+ def scan_sessions(limit: nil, since: nil, until_time: nil, types: nil)
31
35
  scan_cap = limit || store.limits.analytics_max_scan_sessions
32
36
  seen = {}
33
- store.each_session_events(limit: scan_cap, since: since, until_time: until_time) do |summary, window_id, events|
37
+ store.each_session_events(limit: scan_cap, since: since, until_time: until_time, types: types) do |summary, window_id, events|
34
38
  seen[summary[:session_id]] = true
35
39
  yield summary, window_id, events
36
40
  end
@@ -54,10 +58,10 @@ module Sentiero
54
58
  # anchor_ts]; consecutive same-href Metas (same-URL reloads) stay in one
55
59
  # segment. anchor_ts is the WINDOW's first timestamp for every segment:
56
60
  # replay deep-links (?t=offset) are window-relative, never segment-local.
57
- def each_page_segment(events)
61
+ def each_page_segment(events, anchor: nil)
58
62
  return if events.empty?
59
63
 
60
- anchor_ts = events.first&.fetch("timestamp", nil)
64
+ anchor_ts = anchor || events.first&.fetch("timestamp", nil)
61
65
 
62
66
  boundaries = [] # [start_index, url] per href change
63
67
  events.each_with_index do |event, index|
@@ -79,6 +83,14 @@ module Sentiero
79
83
  end
80
84
  end
81
85
 
86
+ # Replay ?t= offsets anchor on the window's TRUE first timestamp, which
87
+ # a typed scan may have filtered out of `events` — prefer the summary's
88
+ # :window_starts over events.first.
89
+ def window_anchor(summary, window_id, events)
90
+ anchor = summary[:window_starts]&.fetch(window_id, nil)
91
+ anchor.nil? ? events.first&.fetch("timestamp", nil) : anchor
92
+ end
93
+
82
94
  def meta_href(event)
83
95
  return nil unless event.is_a?(Hash) && event["type"] == META
84
96
 
@@ -21,8 +21,8 @@ module Sentiero
21
21
  rows = []
22
22
  truncated = false
23
23
 
24
- _scanned, hit_cap = scan_sessions(since: since, until_time: until_time) do |summary, window_id, events|
25
- anchor = events.first&.fetch("timestamp", nil)
24
+ _scanned, hit_cap = scan_sessions(since: since, until_time: until_time, types: [META, CUSTOM]) do |summary, window_id, events|
25
+ anchor = window_anchor(summary, window_id, events)
26
26
  events.each do |event|
27
27
  next unless browser_event?(event)
28
28
 
@@ -32,7 +32,7 @@ module Sentiero
32
32
  sessions = {}
33
33
  @truncated = false
34
34
 
35
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
35
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [META, CUSTOM]) do |summary, window_id, events|
36
36
  session_id = summary[:session_id]
37
37
  state = sessions[session_id] ||= new_state(summary, window_id)
38
38
 
@@ -49,7 +49,7 @@ module Sentiero
49
49
  def analyze(limit: nil, since: nil, until_time: nil)
50
50
  accumulators = {}
51
51
 
52
- scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
52
+ scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [INCREMENTAL, META, CUSTOM]) do |summary, window_id, events|
53
53
  session_id = summary[:session_id]
54
54
  acc = (accumulators[session_id] ||= new_accumulator(summary, window_id))
55
55
  accumulate_window(acc, events)
@@ -19,7 +19,7 @@ module Sentiero
19
19
  def grouped_errors(sort_by: "count", since: nil, until_time: nil)
20
20
  groups = {}
21
21
 
22
- _scanned, hit_cap = scan_sessions(since: since, until_time: until_time) do |summary, window_id, events|
22
+ _scanned, hit_cap = scan_sessions(since: since, until_time: until_time, types: [META, CUSTOM]) do |summary, window_id, events|
23
23
  collect_window(groups, summary, window_id, events)
24
24
  end
25
25
 
@@ -32,7 +32,7 @@ module Sentiero
32
32
  private
33
33
 
34
34
  def collect_window(groups, summary, window_id, events)
35
- anchor = events.first&.fetch("timestamp", nil)
35
+ anchor = window_anchor(summary, window_id, events)
36
36
 
37
37
  events.each do |event|
38
38
  next unless ErrorCollector.error_event?(event)
@@ -95,7 +95,7 @@ module Sentiero
95
95
  headers = %w[session_id window_id timestamp tag]
96
96
  rows = []
97
97
 
98
- store.each_session_events(limit: scan_cap, since: since, until_time: until_time) do |summary, window_id, events|
98
+ store.each_session_events(limit: scan_cap, since: since, until_time: until_time, types: [CUSTOM]) do |summary, window_id, events|
99
99
  events.each do |event|
100
100
  next unless event["type"] == CUSTOM
101
101
  tag = event.dig("data", "tag")
@@ -47,7 +47,7 @@ module Sentiero
47
47
  pages = {}
48
48
  accumulation_capped = false
49
49
 
50
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
50
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [INCREMENTAL, META, CUSTOM]) do |summary, window_id, events|
51
51
  incidents = Frustration::Detectors.detect_frustration_events(events)
52
52
  next if incidents.empty?
53
53
 
@@ -44,10 +44,10 @@ module Sentiero
44
44
  sessions = {}
45
45
  accumulation_capped = false
46
46
 
47
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
47
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [META, CUSTOM]) do |summary, window_id, events|
48
48
  session_id = summary[:session_id]
49
49
 
50
- anchor = events.first&.fetch("timestamp", nil)
50
+ anchor = window_anchor(summary, window_id, events)
51
51
  events.each do |event|
52
52
  tag = custom_tag(event)
53
53
  next unless tag
@@ -20,7 +20,7 @@ module Sentiero
20
20
  clicks = ClickCollector.new
21
21
  representative = nil
22
22
 
23
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
23
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [INCREMENTAL, META, CUSTOM]) do |summary, window_id, events|
24
24
  session_id = summary[:session_id]
25
25
 
26
26
  each_page_segment(events) do |url, segment, _anchor|
@@ -43,7 +43,7 @@ module Sentiero
43
43
  def build_heatmap_table(since: nil, until_time: nil)
44
44
  selectors_by_url = {}
45
45
 
46
- scan_sessions(since: since, until_time: until_time) do |_summary, _window_id, events|
46
+ scan_sessions(since: since, until_time: until_time, types: [INCREMENTAL, META, CUSTOM]) do |_summary, _window_id, events|
47
47
  each_page_segment(events) do |url, segment, _anchor|
48
48
  next unless url
49
49
 
@@ -64,7 +64,7 @@ module Sentiero
64
64
  def recorded_urls
65
65
  urls = {}
66
66
 
67
- scan_sessions do |_summary, _window_id, events|
67
+ scan_sessions(types: [INCREMENTAL, META, CUSTOM]) do |_summary, _window_id, events|
68
68
  each_page_segment(events) do |url, _segment, _anchor|
69
69
  urls[url] = true if url && (urls.key?(url) || urls.size < MAX_URLS)
70
70
  end
@@ -35,7 +35,7 @@ module Sentiero
35
35
  def analyze(target_url, limit: nil, since: nil, until_time: nil)
36
36
  acc = new_accumulator
37
37
 
38
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
38
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [INCREMENTAL, META, CUSTOM]) do |summary, window_id, events|
39
39
  session_id = summary[:session_id]
40
40
 
41
41
  # Detect over the FULL window: frustration semantics span page
@@ -48,7 +48,7 @@ module Sentiero
48
48
  first_was_target = false
49
49
  target_segments = 0
50
50
 
51
- each_page_segment(events) do |url, segment, anchor|
51
+ each_page_segment(events, anchor: window_anchor(summary, window_id, events)) do |url, segment, anchor|
52
52
  matches = url == target_url
53
53
  first_was_target = true if segment_index.zero? && matches
54
54
  last_index = segment_index if matches
@@ -13,7 +13,7 @@ module Sentiero
13
13
  def analyze(limit: nil, since: nil, until_time: nil)
14
14
  scroll = ScrollCollector.new(max_urls: MAX_URLS)
15
15
 
16
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |_summary, _window_id, events|
16
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [INCREMENTAL, META]) do |_summary, _window_id, events|
17
17
  each_page_segment(events) do |url, segment, _anchor|
18
18
  scroll.observe(url, segment) if url
19
19
  end
@@ -5,7 +5,7 @@ require_relative "../user_agent"
5
5
 
6
6
  module Sentiero
7
7
  module Analytics
8
- # Filters sessions on read by browser/device/URL/metadata/has-errors/duration
8
+ # Filters sessions on read by browser/device/country/URL/metadata/has-errors/duration
9
9
  # (AND-combined), scanning up to the store's limits.analytics_max_scan_sessions.
10
10
  class Segmenter < Analyzer
11
11
  def initialize(
@@ -19,6 +19,7 @@ module Sentiero
19
19
  has_errors: false,
20
20
  min_duration_ms: nil,
21
21
  max_duration_ms: nil,
22
+ country: nil,
22
23
  since: nil,
23
24
  until_time: nil
24
25
  )
@@ -32,6 +33,8 @@ module Sentiero
32
33
  @has_errors = has_errors
33
34
  @min_duration_ms = min_duration_ms
34
35
  @max_duration_ms = max_duration_ms
36
+ # Normalized: Cloudflare sends uppercase ISO-2, but a custom proc may not.
37
+ @country = presence(country)&.upcase
35
38
  @since = since
36
39
  @until_time = until_time
37
40
  end
@@ -40,6 +43,9 @@ module Sentiero
40
43
  scan_cap = store.limits.analytics_max_scan_sessions
41
44
 
42
45
  scanned = store.list_sessions(limit: scan_cap, offset: 0, since: @since, until_time: @until_time)
46
+ # Collected pre-filter, in the same scan (no extra store pass): the
47
+ # dropdown must list every scanned country, not just the current selection.
48
+ countries = scanned.filter_map { |s| s[:metadata]&.[]("geo_country") }.uniq.sort
43
49
  matches = scanned.select { |summary| match?(summary) }
44
50
 
45
51
  page = matches.slice(offset, limit + 1) || []
@@ -48,7 +54,8 @@ module Sentiero
48
54
  {
49
55
  sessions: page.first(limit),
50
56
  has_next: has_next,
51
- was_truncated: scanned.size >= scan_cap
57
+ was_truncated: scanned.size >= scan_cap,
58
+ countries: countries
52
59
  }
53
60
  end
54
61
 
@@ -65,6 +72,7 @@ module Sentiero
65
72
 
66
73
  browser_match?(metadata) &&
67
74
  device_match?(metadata) &&
75
+ country_match?(metadata) &&
68
76
  url_match?(metadata) &&
69
77
  metadata_match?(metadata) &&
70
78
  has_errors_match?(metadata) &&
@@ -83,6 +91,12 @@ module Sentiero
83
91
  UserAgent.device(metadata["userAgent"]) == @device
84
92
  end
85
93
 
94
+ def country_match?(metadata)
95
+ return true unless @country
96
+
97
+ metadata["geo_country"].to_s.upcase == @country
98
+ end
99
+
86
100
  def url_match?(metadata)
87
101
  return true unless @url_pattern
88
102
 
@@ -31,6 +31,8 @@ module Sentiero
31
31
  event_type_breakdown: event_type_breakdown(acc.event_types),
32
32
  browser_distribution: sort_by_count(acc.browsers),
33
33
  device_distribution: sort_by_count(acc.devices),
34
+ country_distribution: sort_by_count(acc.countries),
35
+ city_distribution: sort_by_count(acc.cities),
34
36
  top_entry_pages: top_entry_pages(acc),
35
37
  top_referrers: top_list(acc.referrers, :referrer, StatsAggregator::TOP_LIST_LIMIT),
36
38
  session_duration_buckets: acc.duration_buckets,
@@ -18,7 +18,14 @@ module Sentiero
18
18
 
19
19
  NAVIGATION_TAG = "navigation"
20
20
 
21
- INTERNAL_METADATA_KEYS = %w[userAgent url referrer viewport has_errors entry_url entry_referrer].freeze
21
+ # With native aggregates (SQLite) the scan reads only Meta + Custom; the
22
+ # type histogram, total count and per-day series come from the store in
23
+ # #finalize. Other stores keep the single all-types scan — the aggregate
24
+ # fallbacks would each re-scan the store.
25
+ SCAN_TYPES = [META, CUSTOM].freeze
26
+
27
+ INTERNAL_METADATA_KEYS = %w[userAgent url referrer viewport has_errors entry_url entry_referrer
28
+ geo_country geo_city geo_region geo_timezone].freeze
22
29
 
23
30
  MAX_NAV_KEYS = 200
24
31
  MAX_METADATA_KEYS = 50
@@ -26,6 +33,7 @@ module Sentiero
26
33
  MAX_TAG_SERIES_KEYS = 200
27
34
  MAX_OVERLAY_PROBLEMS = 200
28
35
  MAX_OCCURRENCES_PER_PROBLEM = 500
36
+ MAX_GEO_VALUES = 200
29
37
 
30
38
  DURATION_BUCKETS = [
31
39
  ["0-30s", 30_000],
@@ -44,7 +52,7 @@ module Sentiero
44
52
  acc = new_accumulator(since, until_time)
45
53
  seen_sessions = {}
46
54
 
47
- store.each_session_events(limit: scan_cap, since: since, until_time: until_time) do |summary, _window_id, events|
55
+ store.each_session_events(limit: scan_cap, since: since, until_time: until_time, types: scan_types) do |summary, _window_id, events|
48
56
  accumulate_window(acc, seen_sessions, summary, events)
49
57
  end
50
58
 
@@ -73,7 +81,7 @@ module Sentiero
73
81
  current = {acc: new_accumulator(since, until_time), seen: {}}
74
82
  prior = {acc: new_accumulator(prior_since, prior_until), seen: {}}
75
83
 
76
- store.each_session_events(limit: scan_cap, since: prior_since, until_time: until_time) do |summary, _window_id, events|
84
+ store.each_session_events(limit: scan_cap, since: prior_since, until_time: until_time, types: scan_types) do |summary, _window_id, events|
77
85
  bucket = (summary[:updated_at] >= since) ? current : prior
78
86
  accumulate_window(bucket[:acc], bucket[:seen], summary, events)
79
87
  end
@@ -99,10 +107,27 @@ module Sentiero
99
107
 
100
108
  def finalize(acc, seen_sessions, scan_cap, overlay:)
101
109
  tally_entries(acc, seen_sessions)
110
+ collect_store_aggregates(acc, scan_cap) if store.supports_event_aggregates?
102
111
  overlay_truncated = overlay ? collect_server_overlay(acc) : false
103
112
  ResultBuilder.new(store).build(acc, seen_sessions.size, scan_cap, overlay_truncated)
104
113
  end
105
114
 
115
+ def scan_types
116
+ store.supports_event_aggregates? ? SCAN_TYPES : nil
117
+ end
118
+
119
+ # The figures SCAN_TYPES hides from the scan, recomputed payload-free by
120
+ # the store. The nil bucket (non-integer client types) counts toward the
121
+ # total but not the histogram.
122
+ def collect_store_aggregates(acc, scan_cap)
123
+ type_counts = store.event_type_counts(limit: scan_cap, since: acc.since, until_time: acc.until_time)
124
+ type_counts.each { |type, count| acc.event_types[type] = count if type }
125
+ acc.total_events = type_counts.values.sum
126
+
127
+ store.event_counts_by_day(limit: scan_cap, since: acc.since, until_time: acc.until_time)
128
+ .each { |day, count| acc.per_day_events[day] = count }
129
+ end
130
+
106
131
  # range_days - 1: the start day is itself one of the range_days buckets.
107
132
  def default_since(range_days, until_time)
108
133
  end_date = (until_time ? Time.at(until_time) : Time.now).utc.to_date
@@ -115,6 +140,7 @@ module Sentiero
115
140
  # methods rather than string-typed acc.key lookups.
116
141
  Accumulator = Struct.new(
117
142
  :event_types, :custom_tags, :browser_tags, :browsers, :devices,
143
+ :countries, :cities,
118
144
  :entry_pages, :entry_page_errors, :referrers, :duration_buckets,
119
145
  :total_events, :durations, :since, :until_time,
120
146
  :per_day_events, :per_day_sessions, :per_day_errors, :per_day_tags,
@@ -130,6 +156,8 @@ module Sentiero
130
156
  browser_tags: Hash.new(0),
131
157
  browsers: Hash.new(0),
132
158
  devices: Hash.new(0),
159
+ countries: Hash.new(0),
160
+ cities: Hash.new(0),
133
161
  entry_pages: Hash.new(0),
134
162
  entry_page_errors: Hash.new(0),
135
163
  referrers: Hash.new(0),
@@ -166,6 +194,7 @@ module Sentiero
166
194
  }
167
195
 
168
196
  tally_browser_device(acc, metadata["userAgent"])
197
+ tally_geo(acc, metadata)
169
198
  acc.sessions_with_errors += 1 if metadata["has_errors"]
170
199
 
171
200
  tally_metadata(acc, metadata)
@@ -199,14 +228,17 @@ module Sentiero
199
228
  end
200
229
 
201
230
  def collect_events(acc, events)
231
+ aggregates_native = store.supports_event_aggregates?
202
232
  events.each do |event|
203
233
  next unless in_window?(acc, event["timestamp"])
204
234
 
205
235
  type = event["type"]
206
- acc.event_types[type] += 1
207
- acc.total_events += 1
236
+ unless aggregates_native
237
+ acc.event_types[type] += 1
238
+ acc.total_events += 1
239
+ record_event_day(acc, event)
240
+ end
208
241
  tally_custom_tag(acc, event) if type == CUSTOM
209
- record_event_day(acc, event)
210
242
  record_error_day(acc, event) if error_event?(event)
211
243
  end
212
244
  end
@@ -228,6 +260,13 @@ module Sentiero
228
260
  acc.devices[device] += 1 if device
229
261
  end
230
262
 
263
+ def tally_geo(acc, metadata)
264
+ country = metadata["geo_country"]
265
+ city = metadata["geo_city"]
266
+ bounded_tally(acc.countries, country, MAX_GEO_VALUES) if country.is_a?(String) && !country.empty?
267
+ bounded_tally(acc.cities, city, MAX_GEO_VALUES) if city.is_a?(String) && !city.empty?
268
+ end
269
+
231
270
  def tally_custom_tag(acc, event)
232
271
  data = event["data"]
233
272
  return unless data.is_a?(Hash)
@@ -25,8 +25,8 @@ module Sentiero
25
25
  pages = {} # url => VitalsCollector
26
26
  accumulation_capped = false
27
27
 
28
- _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time) do |summary, window_id, events|
29
- each_page_segment(events) do |url, segment, anchor|
28
+ _scanned, hit_cap = scan_sessions(limit: limit, since: since, until_time: until_time, types: [META, CUSTOM]) do |summary, window_id, events|
29
+ each_page_segment(events, anchor: window_anchor(summary, window_id, events)) do |url, segment, anchor|
30
30
  next unless url
31
31
 
32
32
  collector = collector_for(pages, url)
@@ -4,6 +4,10 @@ module Sentiero
4
4
  class Configuration
5
5
  attr_reader :store
6
6
 
7
+ # Reader-only: mutate via #register / #default_platform=, not by
8
+ # replacing the whole object.
9
+ attr_reader :fingerprint
10
+
7
11
  attr_accessor :cors_origins,
8
12
  :auth_callback,
9
13
  :flush_interval_ms,
@@ -54,6 +58,12 @@ module Sentiero
54
58
  # logic), so they're declared separately from the plain attr_accessor list.
55
59
  attr_reader :session_idle_timeout, :session_max_age
56
60
 
61
+ attr_reader :max_body_size
62
+
63
+ # Geo headers are trivially spoofable unless actually behind the CDN, so
64
+ # trusting them is an explicit opt-in.
65
+ attr_reader :geo_source
66
+
57
67
  ENFORCED_PRIVACY = {
58
68
  maskInputOptions: {password: true}
59
69
  }.freeze
@@ -63,6 +73,7 @@ module Sentiero
63
73
  # keeps the identifier from living forever on never-idle tabs.
64
74
  DEFAULT_SESSION_IDLE_TIMEOUT = 6 * 60 * 60
65
75
  DEFAULT_SESSION_MAX_AGE = 7 * 24 * 60 * 60
76
+ DEFAULT_MAX_BODY_SIZE = 524_288 # 512 KB
66
77
 
67
78
  # Composition root for store caps: a store assigned to the configuration is
68
79
  # bound to the configuration's caps here, so the store itself never reads
@@ -104,6 +115,21 @@ module Sentiero
104
115
  @session_max_age = clamp_positive_seconds(value, DEFAULT_SESSION_MAX_AGE)
105
116
  end
106
117
 
118
+ # Byte cap on ingest request bodies, raw and gunzipped (dense DOMs can
119
+ # push FullSnapshots past the default). No "unlimited" setting: the cap
120
+ # is the gzip-bomb bound and each request may allocate ~2x it, so
121
+ # invalid values fall back to the default.
122
+ def max_body_size=(value)
123
+ @max_body_size = (value.is_a?(Numeric) && value.finite? && value > 0) ? value.to_i : DEFAULT_MAX_BODY_SIZE
124
+ end
125
+
126
+ def geo_source=(value)
127
+ unless value.nil? || value == :cloudflare || value.respond_to?(:call)
128
+ raise ArgumentError, "geo_source must be nil, :cloudflare, or a #call-able (env -> Hash)"
129
+ end
130
+ @geo_source = value
131
+ end
132
+
107
133
  private def clamp_positive_seconds(value, default)
108
134
  (value.is_a?(Numeric) && value.finite? && value > 0) ? value : default
109
135
  end
@@ -115,6 +141,7 @@ module Sentiero
115
141
  @flush_interval_ms = 10_000
116
142
  @flush_event_threshold = 50
117
143
  @max_events_per_page = 1_000
144
+ @max_body_size = DEFAULT_MAX_BODY_SIZE
118
145
  @max_problems = 5_000
119
146
  @max_server_events = 50_000
120
147
  @ingest_keys = {}
@@ -145,7 +172,9 @@ module Sentiero
145
172
  @session_idle_timeout = DEFAULT_SESSION_IDLE_TIMEOUT
146
173
  @session_max_age = DEFAULT_SESSION_MAX_AGE
147
174
  @redaction = Sentiero::Redaction::Config.new
175
+ @fingerprint = Sentiero::Fingerprint::Config.new
148
176
  @anonymize_ip = true
177
+ @geo_source = nil
149
178
  @audit_log = nil
150
179
  # Opt-in: a share file is a full session dump leaving the operator's
151
180
  # infrastructure, so export/import routes 404 until explicitly enabled.
@@ -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