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.
- 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 +2 -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/schema.rb +43 -6
- data/lib/sentiero/stores/sqlite.rb +214 -100
- 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/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/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 +9 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8bc35b365449e9111ed5dcc093ea7fceaf60d739b47e1bf13df8f7cbc83cf55
|
|
4
|
+
data.tar.gz: 39c9a76c8acf2f22d44fcc3fa88d450ef1e36a368434ba5ae143c82bd2800b30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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** —
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|