sentiero 1.0.0.alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +7 -0
  3. data/README.md +679 -0
  4. data/lib/sentiero/analytics/analyzer.rb +91 -0
  5. data/lib/sentiero/analytics/bounded.rb +29 -0
  6. data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
  7. data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
  8. data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
  9. data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
  10. data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
  11. data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
  12. data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
  13. data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
  14. data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
  15. data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
  16. data/lib/sentiero/analytics/entry_attribution.rb +71 -0
  17. data/lib/sentiero/analytics/error_discovery.rb +118 -0
  18. data/lib/sentiero/analytics/events.rb +21 -0
  19. data/lib/sentiero/analytics/exporter.rb +242 -0
  20. data/lib/sentiero/analytics/form_analyzer.rb +153 -0
  21. data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
  22. data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
  23. data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
  24. data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
  25. data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
  26. data/lib/sentiero/analytics/problem_detail.rb +97 -0
  27. data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
  28. data/lib/sentiero/analytics/segmenter.rb +133 -0
  29. data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
  30. data/lib/sentiero/analytics/stats.rb +30 -0
  31. data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
  32. data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
  33. data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
  34. data/lib/sentiero/configuration.rb +184 -0
  35. data/lib/sentiero/erasure.rb +48 -0
  36. data/lib/sentiero/fingerprint.rb +34 -0
  37. data/lib/sentiero/ip_anonymizer.rb +29 -0
  38. data/lib/sentiero/redaction/config.rb +61 -0
  39. data/lib/sentiero/redaction.rb +207 -0
  40. data/lib/sentiero/reporter/configuration.rb +50 -0
  41. data/lib/sentiero/reporter/context.rb +31 -0
  42. data/lib/sentiero/reporter/dispatcher.rb +91 -0
  43. data/lib/sentiero/reporter/http_transport.rb +57 -0
  44. data/lib/sentiero/reporter/log_transport.rb +26 -0
  45. data/lib/sentiero/reporter/middleware.rb +62 -0
  46. data/lib/sentiero/reporter/normalizer.rb +14 -0
  47. data/lib/sentiero/reporter/null_transport.rb +18 -0
  48. data/lib/sentiero/reporter/report_context.rb +29 -0
  49. data/lib/sentiero/reporter/scrubber.rb +47 -0
  50. data/lib/sentiero/reporter/test_helper.rb +32 -0
  51. data/lib/sentiero/reporter/test_transport.rb +28 -0
  52. data/lib/sentiero/reporter.rb +214 -0
  53. data/lib/sentiero/roda.rb +47 -0
  54. data/lib/sentiero/store/error_store.rb +220 -0
  55. data/lib/sentiero/store/limits.rb +31 -0
  56. data/lib/sentiero/store/session_store.rb +118 -0
  57. data/lib/sentiero/store.rb +72 -0
  58. data/lib/sentiero/stores/file.rb +566 -0
  59. data/lib/sentiero/stores/memory.rb +362 -0
  60. data/lib/sentiero/stores/redis/keys.rb +59 -0
  61. data/lib/sentiero/stores/redis/lua.rb +119 -0
  62. data/lib/sentiero/stores/redis.rb +665 -0
  63. data/lib/sentiero/stores/sqlite/schema.rb +79 -0
  64. data/lib/sentiero/stores/sqlite.rb +626 -0
  65. data/lib/sentiero/user_agent.rb +32 -0
  66. data/lib/sentiero/version.rb +5 -0
  67. data/lib/sentiero/web/analytics_app.rb +538 -0
  68. data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
  69. data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
  70. data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
  71. data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
  72. data/lib/sentiero/web/assets/manifest.json +11 -0
  73. data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
  74. data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
  75. data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
  76. data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
  77. data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
  78. data/lib/sentiero/web/assets_app.rb +42 -0
  79. data/lib/sentiero/web/base_app.rb +319 -0
  80. data/lib/sentiero/web/basic_auth.rb +27 -0
  81. data/lib/sentiero/web/basic_auth_check.rb +41 -0
  82. data/lib/sentiero/web/body_reader.rb +44 -0
  83. data/lib/sentiero/web/csv_writer.rb +45 -0
  84. data/lib/sentiero/web/dashboard_app.rb +236 -0
  85. data/lib/sentiero/web/errors_app.rb +97 -0
  86. data/lib/sentiero/web/escaping.rb +37 -0
  87. data/lib/sentiero/web/events_app.rb +196 -0
  88. data/lib/sentiero/web/formatting.rb +43 -0
  89. data/lib/sentiero/web/ingest_app.rb +92 -0
  90. data/lib/sentiero/web/manifest.rb +43 -0
  91. data/lib/sentiero/web/monitoring_app.rb +316 -0
  92. data/lib/sentiero/web/script_tag.rb +57 -0
  93. data/lib/sentiero/web/shareable_replay.rb +88 -0
  94. data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
  95. data/lib/sentiero/web/templates/_brand.html.erb +18 -0
  96. data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
  97. data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
  98. data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
  99. data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
  100. data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
  101. data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
  102. data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
  103. data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
  104. data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
  105. data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
  106. data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
  107. data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
  108. data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
  109. data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
  110. data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
  111. data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
  112. data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
  113. data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
  114. data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
  115. data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
  116. data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
  117. data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
  118. data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
  119. data/lib/sentiero/web/templates/event_show.html.erb +52 -0
  120. data/lib/sentiero/web/templates/events_index.html.erb +177 -0
  121. data/lib/sentiero/web/templates/export_index.html.erb +69 -0
  122. data/lib/sentiero/web/templates/forms.html.erb +105 -0
  123. data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
  124. data/lib/sentiero/web/templates/import.html.erb +39 -0
  125. data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
  126. data/lib/sentiero/web/templates/segments.html.erb +114 -0
  127. data/lib/sentiero/web/templates/session_show.html.erb +195 -0
  128. data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
  129. data/lib/sentiero/web/track_app.rb +57 -0
  130. data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
  131. data/lib/sentiero/web/views/analyzer_view.rb +27 -0
  132. data/lib/sentiero/web/views/base_view.rb +76 -0
  133. data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
  134. data/lib/sentiero/web/views/conversions_view.rb +41 -0
  135. data/lib/sentiero/web/views/engagement_view.rb +67 -0
  136. data/lib/sentiero/web/views/errors_index_view.rb +37 -0
  137. data/lib/sentiero/web/views/event_show_view.rb +20 -0
  138. data/lib/sentiero/web/views/events_index_view.rb +56 -0
  139. data/lib/sentiero/web/views/export_view.rb +23 -0
  140. data/lib/sentiero/web/views/forms_view.rb +28 -0
  141. data/lib/sentiero/web/views/frustration_view.rb +15 -0
  142. data/lib/sentiero/web/views/funnel_view.rb +36 -0
  143. data/lib/sentiero/web/views/heatmap_view.rb +34 -0
  144. data/lib/sentiero/web/views/import_view.rb +13 -0
  145. data/lib/sentiero/web/views/page_report_view.rb +43 -0
  146. data/lib/sentiero/web/views/problem_show_view.rb +46 -0
  147. data/lib/sentiero/web/views/scroll_view.rb +23 -0
  148. data/lib/sentiero/web/views/segments_view.rb +28 -0
  149. data/lib/sentiero/web/views/session_show_view.rb +105 -0
  150. data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
  151. data/lib/sentiero/web/views/vitals_view.rb +45 -0
  152. data/lib/sentiero/web/views.rb +24 -0
  153. data/lib/sentiero/window_ref.rb +6 -0
  154. data/lib/sentiero.rb +69 -0
  155. metadata +232 -0
data/README.md ADDED
@@ -0,0 +1,679 @@
1
+ <p align="center">
2
+ <img src="logo.svg" alt="Sentiero" width="80" />
3
+ </p>
4
+
5
+ <h1 align="center">Sentiero</h1>
6
+
7
+ <p align="center">
8
+ <strong>In-app browser session recording and replay for Ruby.</strong><br>
9
+ Self-hosted. Privacy-first. Framework-agnostic.
10
+ </p>
11
+
12
+ <hr/>
13
+
14
+ **Browser session recording for Ruby. Like Hotjar etc.**
15
+
16
+ Playback user journeys through your app to help debug issues, improve UX and understand intent.
17
+
18
+ Captures user interactions via [rrweb](https://www.rrweb.io/), stores them server-side with pluggable storage, and replays sessions from a built-in dashboard.
19
+
20
+ Similar to [SpectatorSport](https://github.com/bensheldon/spectator_sport) by Ben Sheldon but not bound to Rails.
21
+
22
+ 📖 **Full documentation: [sentiero.app](https://sentiero.app)**
23
+
24
+ ### Why Sentiero?
25
+
26
+ - **De-SaaS your session recording** — keep user interaction data in your own infrastructure instead of sending it to third-party services
27
+ - **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
29
+ - **Framework-agnostic** — drop into any Rack-compatible app, or use the dedicated Rails integration
30
+ - **Complete but focused** — session recording, replay, and the tools around them, without trying to be an analytics platform
31
+
32
+ ## Features
33
+
34
+ Privacy-first defaults, no framework coupling, works with any Rack-based app.
35
+
36
+ - Replay dashboard with interactive event timeline, activity sidebar, and keyboard shortcuts
37
+ - Full DOM recording via [rrweb](https://www.rrweb.io/) with cross-tab session linking
38
+ - Privacy-first, inputs masked by default, password masking enforced
39
+ - Works with any Rack app (Rails, Roda, Sinatra)
40
+
41
+ Also:
42
+
43
+ - Session metadata, captures URL, browser, viewport, referrer (opt-in)
44
+ - Navigation tracking, automatic outbound link logging (opt-in)
45
+ - 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/))
47
+ - JSON export and shareable deep-links with timestamp
48
+ - 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
+ - Cross-session [Analytics](#analytics), pages, segments, errors, heatmaps, scroll, and forms across all recorded sessions
50
+ - [Shareable replays](#shareable-replays), export a self-contained HTML replay or play one back from JSON (opt-in)
51
+ - Privacy/compliance suite, end-user opt-out, GPC respect, server-side sanitization, IP anonymization, retention/purge, and right-to-erasure ([see below](#compliance))
52
+ - Pluggable storage, Memory, File, SQLite, Redis, or bring your own
53
+ - Gzip compression, smart batching, `sendBeacon` on page close, retry with backoff
54
+
55
+ > Run the [demo app](#demo-app) to see it in action: `demo/run` then visit `localhost:9292`.
56
+
57
+ | Demo app | Session list | Session replay |
58
+ |----------|-------------|----------------|
59
+ | ![Demo app](demo/screenshot-demo-app.png) | ![Session list](demo/screenshot-index.png) | ![Session replay](demo/screenshot-recording.png) |
60
+
61
+ ## Installation
62
+
63
+ Add to your Gemfile:
64
+
65
+ ```ruby
66
+ gem "sentiero"
67
+
68
+ # Optional, pick one for persistent storage:
69
+ gem "redis", ">= 4.0" # for Redis store
70
+ gem "sqlite3", ">= 1.4" # for SQLite store
71
+ ```
72
+
73
+ ## Quick Start
74
+
75
+ ### 1. Configure
76
+
77
+ ```ruby
78
+ require "sentiero"
79
+
80
+ Sentiero.configure do |config|
81
+ config.store = Sentiero::Stores::Memory.new
82
+ config.cors_origins = ["http://localhost:3000"]
83
+ end
84
+ ```
85
+
86
+ ### 2. Mount the Endpoints
87
+
88
+ ```ruby
89
+ # Roda (via plugin)
90
+ require "sentiero/roda"
91
+
92
+ class MyApp < Roda
93
+ plugin :sentiero
94
+
95
+ route do |r|
96
+ r.on "sentiero" do
97
+ r.on("events") { r.sentiero_events }
98
+ r.sentiero_dashboard
99
+ end
100
+ end
101
+ end
102
+ ```
103
+
104
+ ```ruby
105
+ # Plain Rack (config.ru)
106
+ map("/sentiero/events") { run Sentiero::Web::EventsApp.new }
107
+ map("/sentiero") { run Sentiero::Web::DashboardApp.new }
108
+ ```
109
+
110
+ ```ruby
111
+ # Rails (routes.rb)
112
+ mount Sentiero::Web::EventsApp.new => "/sentiero/events"
113
+ mount Sentiero::Web::DashboardApp.new => "/sentiero"
114
+ ```
115
+
116
+ Two mounts are all you need: `DashboardApp` is the single dashboard entry
117
+ point and internally dispatches `/analytics/*`, `/issues/*`, `/custom-events/*`,
118
+ `/assets/*`, and `/recorder.js`. Don't mount `AnalyticsApp` or `MonitoringApp`
119
+ at their own paths alongside it — that creates routing conflicts.
120
+
121
+ > **Behind a reverse proxy?** Mount Sentiero at the same path the proxy
122
+ > forwards, and don't strip the prefix: the dashboard builds its links from
123
+ > Rack's `SCRIPT_NAME`, which `map`/`mount` set. With a prefix-stripping proxy
124
+ > (Caddy's `handle_path`, nginx `proxy_pass` with a trailing slash),
125
+ > `SCRIPT_NAME` stays empty and every internal link points at the root. Use
126
+ > Caddy's `handle` (not `handle_path`) / nginx `proxy_pass` without a URI, and
127
+ > keep the `map("/sentiero")` in your config.ru.
128
+
129
+ ### 3. Add the Recording Script
130
+
131
+ Include in your HTML layout (before `</body>`):
132
+
133
+ ```erb
134
+ <%= Sentiero::Web::ScriptTag.render(events_url: "/sentiero/events") %>
135
+
136
+ <%# Or with the Roda plugin helper: %>
137
+ <%= sentiero_script_tag(events_url: "/sentiero/events") %>
138
+ ```
139
+
140
+ That's it. Sessions are now being recorded and viewable at `/sentiero/`.
141
+
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.
143
+
144
+ > **Going to production?** Read the [Production Checklist](#production-checklist) first.
145
+
146
+ ## Authentication
147
+
148
+ **The dashboard has no authentication by default.** Anyone who can reach the URL can view and delete recorded sessions.
149
+
150
+ There are two approaches to protect it:
151
+
152
+ **1. `auth_callback`, for session-based auth** (Devise, Warden, custom sessions):
153
+
154
+ ```ruby
155
+ Sentiero.configure do |config|
156
+ config.auth_callback = ->(env) { env["warden"]&.user&.admin? }
157
+ end
158
+ ```
159
+
160
+ Denied requests get a `403 Forbidden`. This works when the user already has a session cookie, it won't trigger a browser login dialog.
161
+
162
+ **2. Route-level auth, for challenge-based auth** (HTTP Basic, OAuth redirects):
163
+
164
+ ```ruby
165
+ # Roda example (from the demo app)
166
+ r.on "sentiero" do
167
+ r.on("events") { r.sentiero_events }
168
+
169
+ auth = Rack::Auth::Basic::Request.new(r.env)
170
+ unless auth.provided? && auth.basic? && auth.credentials == [user, password]
171
+ r.halt [401, {"www-authenticate" => 'Basic realm="Dashboard"'}, ["Unauthorized"]]
172
+ end
173
+ r.sentiero_dashboard
174
+ end
175
+ ```
176
+
177
+ Use route-level auth when you need `401`/`302` responses (e.g., HTTP Basic prompts, OAuth redirects). `auth_callback` only returns `403`.
178
+
179
+ 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
+
181
+ See [the authentication guide](https://sentiero.app/guide/authentication/) for the full guide including Rails, Sinatra, and plain Rack examples.
182
+
183
+ ## Privacy
184
+
185
+ All DOM inputs are masked by default, values entered into form fields are not sent to the backend.
186
+
187
+ Password masking is **enforced and cannot be disabled**.
188
+
189
+ > **Input masking ≠ all PII.** Masking covers values the user *types*. PII the
190
+ > server *renders into the page* — `Welcome, Ada Lovelace!`, an order summary, an
191
+ > account number — is captured as ordinary DOM text. Mark those elements with
192
+ > `data-rr-mask` (mask the text) or `data-rr-block` (drop the element entirely):
193
+ >
194
+ > ```erb
195
+ > <h1 data-rr-mask>Welcome, <%= current_user.name %>!</h1>
196
+ > ```
197
+ >
198
+ > As a server-side backstop for text you can pattern-match but can't annotate,
199
+ > add a pattern to `config.redaction.custom_patterns` or a
200
+ > `config.redaction.server_proc` hook (see [Compliance](#compliance)).
201
+
202
+ You can control which inputs are masked and recorded. You can also block whole sections of content, useful for areas that may contain PII or sensitive information (user-generated content, one-time token displays, etc.).
203
+
204
+ ### Per-Element Control
205
+
206
+ Use HTML attributes to control recording on individual elements:
207
+
208
+ ```html
209
+ <div data-rr-block>Blocked from recording entirely</div>
210
+ <span data-rr-mask>Text content masked in replay</span>
211
+ <input data-rr-ignore> <!-- Mutations not recorded -->
212
+ <input type="password"> <!-- Always masked, enforced -->
213
+ ```
214
+
215
+ ### Selective Unmasking
216
+
217
+ When global masking is on (the default), you can selectively unmask specific inputs or sections with `data-sentiero-unmask`:
218
+
219
+ ```html
220
+ <!-- Unmask a single input -->
221
+ <input type="text" name="search" data-sentiero-unmask>
222
+
223
+ <!-- Unmask an entire section (applies to all inputs and text within) -->
224
+ <div data-sentiero-unmask>
225
+ <input type="text" name="first_name">
226
+ <input type="text" name="last_name">
227
+ <span>This text content is also unmasked</span>
228
+ </div>
229
+ ```
230
+
231
+ In Rails ERB:
232
+
233
+ ```erb
234
+ <%= f.text_field :search, data: { sentiero_unmask: true } %>
235
+ ```
236
+
237
+ **Important:** Password inputs remain masked even with `data-sentiero-unmask`. Password masking is enforced by rrweb independently and cannot be bypassed.
238
+
239
+ **Known limitation:** Some rrweb versions don't consistently call masking functions during the initial full DOM snapshot. Pre-filled input values present on page load may appear masked in the snapshot even if marked with `data-sentiero-unmask`. Values captured from input events after page load work correctly.
240
+
241
+ ### Compliance
242
+
243
+ Sentiero ships a compliance toolkit for GDPR/CCPA-style obligations:
244
+
245
+ - **End-user opt-out** — set `config.user_opt_out = true` to expose `window.Sentiero.optOut()` / `optIn()` in the browser. Opting out drops a cookie (`config.opt_out_cookie_name`, default `"sentiero_optout"`) and stops recording across sessions; the server respects the same cookie.
246
+ - **Global Privacy Control** — `config.respect_gpc` (default `true`) suppresses recording for visitors sending the GPC signal.
247
+ - **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
+ - **IP anonymization** — `config.anonymize_ip` (default `true`) truncates client IPs before storage; set to `false` to keep raw IPs.
249
+ - **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.
251
+ - **Audit hook** — `config.audit_log` receives compliance-relevant events (opt-outs, erasures, purges) for your own logging.
252
+
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.
254
+
255
+ ## Analytics
256
+
257
+ Beyond single-session replay, Sentiero includes a cross-session analytics dashboard that aggregates behavior across all recorded sessions. It's served under the dashboard mount at `/analytics`:
258
+
259
+ | Path | View |
260
+ |------|------|
261
+ | `/analytics` | Pages overview |
262
+ | `/analytics/segments` | Segments (browser, viewport, referrer, etc.) |
263
+ | `/analytics/errors` | Captured JS errors |
264
+ | `/analytics/heatmap` | Click heatmap |
265
+ | `/analytics/scroll` | Scroll-depth |
266
+ | `/analytics/forms` | Form interactions |
267
+ | `/analytics/export` | Export aggregated data |
268
+
269
+ Analytics are **compute-on-read**: there are no rollup tables, the analyzers query the store and aggregate at request time. To keep that bounded, each request scans at most `config.analytics_max_scan_sessions` sessions (default `5000`).
270
+
271
+ DashboardApp serves these routes automatically. To mount the analytics UI on its own, use the Roda helper `r.sentiero_analytics`.
272
+
273
+ See [the analytics guide](https://sentiero.app/guide/analytics/) for details.
274
+
275
+ ## Shareable Replays
276
+
277
+ You can hand a single session to someone who has no access to your dashboard:
278
+
279
+ - **HTML export** — `GET /analytics/share/:id` produces a standalone, self-contained HTML file that replays the session with no server needed.
280
+ - **JSON import** — `/analytics/import` plays a session back from a previously exported JSON dump.
281
+
282
+ Both are gated by `config.shareable_replays` (default `false`); while disabled, the routes return `404`.
283
+
284
+ > **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
+
286
+ See [the sharing guide](https://sentiero.app/guide/sharing/) for details.
287
+
288
+ ## Server-side Error Tracking
289
+
290
+ Sentiero ships a server-side **reporter** that sends unhandled exceptions and
291
+ custom events to a Sentiero ingest endpoint, where they are fingerprinted into
292
+ issues. When a server error happens during a recorded session, the reporter
293
+ links it back to the front-end replay via the `sentiero_sid` / `sentiero_wid`
294
+ cookies, so you can watch what the user did right before it broke.
295
+
296
+ The reporter is **fail-safe**: every public method rescues internally and never
297
+ raises into your app.
298
+
299
+ ### Install
300
+
301
+ The reporter lives in the same gem, but isn't required by default. Require it
302
+ where you configure it (the Rails initializer does this for you):
303
+
304
+ ```ruby
305
+ require "sentiero/reporter"
306
+ ```
307
+
308
+ ### Configure
309
+
310
+ ```ruby
311
+ Sentiero::Reporter.configure do |r|
312
+ r.endpoint = ENV["SENTIERO_ENDPOINT"] # e.g. "https://sentiero.example.com"
313
+ r.ingest_key = ENV["SENTIERO_INGEST_KEY"] # server-issued ingest key
314
+ r.project = "my-app" # project identifier
315
+ r.environment = "production" # added to every report's context
316
+ r.release = ENV["GIT_SHA"] # optional release/version
317
+
318
+ r.ignore_exceptions = [ActiveRecord::RecordNotFound, "ActionController::RoutingError"]
319
+ r.before_notify = ->(report) {
320
+ report["context"].delete("secret")
321
+ report # return false/nil to drop the report
322
+ }
323
+ r.filter_keys = [:password, :token, /secret/i]
324
+ end
325
+ ```
326
+
327
+ The reporter is **active** only when `endpoint`, `ingest_key`, and `project` are
328
+ all set and `enabled` is true (the default). Until then, `notify`/`track` are
329
+ no-ops.
330
+
331
+ ### Report errors and events
332
+
333
+ ```ruby
334
+ # Report a caught exception
335
+ begin
336
+ risky!
337
+ rescue => e
338
+ Sentiero::Reporter.notify(e, context: { user_id: current_user.id })
339
+ raise
340
+ end
341
+
342
+ # Track a custom event (a non-error business signal)
343
+ Sentiero::Reporter.track("signup", level: "info", plan: "pro")
344
+ ```
345
+
346
+ ### Context
347
+
348
+ Attach context once and have it merged into every report from the current
349
+ thread:
350
+
351
+ ```ruby
352
+ Sentiero::Reporter.add_context(user_id: 42) # sticky for this thread
353
+
354
+ Sentiero::Reporter.with_context(request_id: "abc") do # scoped to the block
355
+ Sentiero::Reporter.notify(error)
356
+ end
357
+
358
+ Sentiero::Reporter.clear_context
359
+ ```
360
+
361
+ Putting `session_id` / `window_id` in context (the Rack middleware does this
362
+ from the recorder cookies) links the report to a session replay.
363
+
364
+ ### Filtering noise
365
+
366
+ - `ignore_exceptions` — an array of exception classes or `"String"` class-names;
367
+ matches the exception class **or any ancestor**, so a subclass of an ignored
368
+ error is also dropped.
369
+ - `before_notify` — a proc called with the assembled, mutable report hash. Mutate
370
+ it in place to enrich/redact, or return `false`/`nil` to drop the report. A
371
+ raising hook is caught and the original report is delivered unchanged.
372
+
373
+ ### Transports
374
+
375
+ By default the reporter posts JSON to your endpoint. For development/test you
376
+ can swap the transport:
377
+
378
+ ```ruby
379
+ r.transport = Sentiero::Reporter::LogTransport.new # logs would-be deliveries
380
+ r.transport = Sentiero::Reporter::NullTransport.new # drops everything
381
+ ```
382
+
383
+ In your own test suite, capture what would have been sent. The helper is not
384
+ loaded by default — require it from your test setup:
385
+
386
+ ```ruby
387
+ require "sentiero/reporter/test_helper"
388
+
389
+ captured = Sentiero::Reporter::TestHelper.capture_notifications do
390
+ do_something_that_reports
391
+ end
392
+ # => [["errors", {...}], ["track", {...}]]
393
+ ```
394
+
395
+ ### Rails auto-install
396
+
397
+ With `sentiero-rails`, configuring the reporter is all you need — the engine
398
+ auto-inserts `Sentiero::Reporter::Middleware` into your middleware stack (it
399
+ captures and re-raises unhandled exceptions, and reads the session/window
400
+ cookies into context). Opt out with `Sentiero::Rails.configure { |c| c.reporter_middleware = false }`.
401
+ For non-Rails apps, add the middleware yourself: `use Sentiero::Reporter::Middleware`.
402
+
403
+ See [the error tracking guide](https://sentiero.app/guide/error-tracking/) for the full guide
404
+ (architecture, the `/issues` and `/custom-events` dashboards, deployment, and
405
+ the Crystal shard).
406
+
407
+ ## Configuration
408
+
409
+ ```ruby
410
+ Sentiero.configure do |config|
411
+ # Storage backend (required)
412
+ config.store = Sentiero::Stores::Redis.new(
413
+ redis: Redis.new(url: ENV["REDIS_URL"]),
414
+ ttl: 86_400 * 7, # auto-expire after 7 days
415
+ prefix: "sentiero:" # Redis key namespace
416
+ )
417
+
418
+ # CORS origins for the events endpoint
419
+ config.cors_origins = ["https://mysite.com"]
420
+
421
+ # Dashboard auth (receives Rack env, return truthy to allow)
422
+ config.auth_callback = ->(env) { env["rack.session"]&.dig("admin") }
423
+
424
+ # Frontend flush tuning
425
+ config.flush_interval_ms = 10_000 # time-based flush (ms), default: 10,000
426
+ config.flush_event_threshold = 50 # count-based flush, default: 50
427
+ config.max_events_per_page = 1_000 # events API pagination, default: 1,000
428
+
429
+ # Resource limits (nil = unlimited)
430
+ config.max_events_per_request = 500 # max events in a single POST
431
+ config.max_sessions = 10_000 # max sessions in store (LRU eviction)
432
+ config.max_events_per_session = 50_000 # max events per session (oldest dropped)
433
+
434
+ # Cross-tab session linking (default: true)
435
+ # When true, all tabs share a session ID (via localStorage).
436
+ # When false, each tab gets its own session (via sessionStorage).
437
+ config.cross_tab_sessions = true
438
+
439
+ # Opt-in features (all default: false)
440
+ config.capture_metadata = true # capture URL, browser, viewport, referrer
441
+ config.capture_errors = true # capture JS errors as timeline events
442
+ config.track_navigation = true # log outbound link clicks as events
443
+ config.track_custom_events = true # enable declarative data-sentiero-track-* attributes
444
+ config.track_forms = true # capture real form submits for form analytics (attrs only, never values)
445
+
446
+ # rrweb recorder options (Ruby-style snake_case)
447
+ config.mask_all_inputs = true # default: true
448
+ config.block_selector = "[data-rr-block]" # default: "[data-rr-block]"
449
+ config.mask_text_selector = "[data-rr-mask]" # default: "[data-rr-mask]"
450
+ config.ignore_selector = "[data-rr-ignore]" # default: "[data-rr-ignore]"
451
+ config.sampling = { scroll: 150, input: "last" } # default shown
452
+ config.inline_stylesheet = true # default: nil (rrweb default)
453
+ config.checkout_every_n_ms = 30_000 # default: nil (rrweb default)
454
+
455
+ # Escape hatch for rrweb options without a first-class attribute.
456
+ # Pass camelCase keys; they're forwarded to rrweb verbatim.
457
+ # First-class attributes above take precedence for overlapping keys.
458
+ config.recorder_options = { someObscureOption: true }
459
+ end
460
+ ```
461
+
462
+ | Option | Type | Default | Description |
463
+ |--------|------|---------|-------------|
464
+ | `store` | `Sentiero::Store` | `nil` (required) | Storage backend instance |
465
+ | `cors_origins` | `Array<String>` | `[]` | Allowed CORS origins for EventsApp |
466
+ | `auth_callback` | `Proc` / `nil` | `nil` | Dashboard auth; `nil` = open access |
467
+ | `flush_interval_ms` | `Integer` | `10_000` | Frontend time-based flush interval |
468
+ | `flush_event_threshold` | `Integer` | `50` | Frontend count-based flush threshold |
469
+ | `max_events_per_page` | `Integer` | `1_000` | Max events returned per API page |
470
+ | `max_events_per_request` | `Integer` / `nil` | `nil` | Max events accepted per POST; `nil` = unlimited |
471
+ | `max_sessions` | `Integer` / `nil` | `nil` | Max sessions in store; oldest evicted when exceeded; `nil` = unlimited |
472
+ | `max_events_per_session` | `Integer` / `nil` | `nil` | Max events per session; oldest dropped when exceeded; `nil` = unlimited |
473
+ | `cross_tab_sessions` | `Boolean` | `true` | Link sessions across browser tabs; `false` isolates each tab as its own session |
474
+ | `session_idle_timeout` | `Integer` | `21_600` (6 h) | Inactivity gap (seconds) after which a returning visitor starts a new session |
475
+ | `session_max_age` | `Integer` | `604_800` (7 d) | Hard cap (seconds) on a session ID's lifetime, even while continuously active |
476
+ | `capture_metadata` | `Boolean` | `false` | Capture page URL, browser, viewport, and referrer per session |
477
+ | `capture_errors` | `Boolean` | `false` | Capture JS errors and unhandled promise rejections as timeline events |
478
+ | `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/)) |
480
+ | `track_forms` | `Boolean` | `false` | Capture real form submits as `__form_submit` events for form analytics (form `name`/`id` attributes + page URL — never values) |
481
+ | `mask_all_inputs` | `Boolean` | `true` | Mask all form input values in recordings |
482
+ | `mask_input_options` | `Hash` | `{}` | Per-input-type masking; `password: true` is always enforced |
483
+ | `block_selector` | `String` / `nil` | `"[data-rr-block]"` | CSS selector for elements excluded from recording |
484
+ | `mask_text_selector` | `String` / `nil` | `"[data-rr-mask]"` | CSS selector for elements with masked text content |
485
+ | `ignore_selector` | `String` / `nil` | `"[data-rr-ignore]"` | CSS selector for elements whose mutations are ignored |
486
+ | `sampling` | `Hash` / `nil` | `{ scroll: 150, input: "last" }` | Throttling for scroll/input events |
487
+ | `inline_stylesheet` | `Boolean` / `nil` | `nil` | Inline stylesheets into the recording snapshot |
488
+ | `checkout_every_n_ms` | `Integer` / `nil` | `nil` | Full DOM checkout interval in milliseconds |
489
+ | `recorder_options` | `Hash` | `{}` | Escape hatch: raw camelCase rrweb options forwarded verbatim; first-class attributes above take precedence for overlapping keys |
490
+ | `capture_web_vitals` | `Boolean` | `false` | Capture Web Vitals (LCP, CLS, etc.) and show them as badges in replay |
491
+ | `analytics_max_scan_sessions` | `Integer` | `5000` | Max sessions scanned per compute-on-read analytics request |
492
+ | `shareable_replays` | `Boolean` | `false` | Enable HTML-export and JSON-import share routes; `false` makes them `404` |
493
+ | `user_opt_out` | `Boolean` | `false` | Expose `window.Sentiero.optOut()` / `optIn()` and honor the opt-out cookie |
494
+ | `opt_out_cookie_name` | `String` | `"sentiero_optout"` | Cookie name used to persist an end-user opt-out |
495
+ | `respect_gpc` | `Boolean` | `true` | Suppress recording for visitors sending a Global Privacy Control signal |
496
+ | `retention_period` | `Integer` / `nil` | `nil` | Retention window in seconds for `purge_expired!`; `nil` = keep forever |
497
+ | `redaction` | `Redaction::Config` | builtin defaults | Client+server redaction engine (`url_mode`, `custom_patterns`, `server_proc`); fail-closed |
498
+ | `anonymize_ip` | `Boolean` | `true` | Truncate client IP addresses before storage |
499
+ | `audit_log` | `Proc` / `nil` | `nil` | Hook receiving compliance events (opt-outs, erasures, purges) |
500
+
501
+ ### Reporter configuration (`Sentiero::Reporter.configure`)
502
+
503
+ These keys live on `Sentiero::Reporter::Configuration`, not the recorder config above.
504
+
505
+ | Option | Type | Default | Description |
506
+ |--------|------|---------|-------------|
507
+ | `endpoint` | `String` / `nil` | `nil` | Ingest base URL; required for the reporter to be active |
508
+ | `ingest_key` | `String` / `nil` | `nil` | Bearer ingest key; required for the reporter to be active |
509
+ | `project` | `String` / `nil` | `nil` | Project identifier; required for the reporter to be active |
510
+ | `environment` | `String` / `nil` | `nil` | Added to every report's context |
511
+ | `release` | `String` / `nil` | `nil` | Release/version added to every report's context |
512
+ | `enabled` | `Boolean` | `true` | Master switch; `false` makes `notify`/`track` no-ops |
513
+ | `ignore_exceptions` | `Array<Class, String>` | `[]` | Exceptions to drop, matched by class/ancestor or class-name string |
514
+ | `before_notify` | `Proc` / `nil` | `nil` | Hook to mutate the report hash or drop it (return `false`/`nil`) |
515
+ | `filter_keys` | `Array<String, Symbol, Regexp>` | `[]` | Context/payload keys to redact before sending |
516
+ | `async` | `Boolean` | `true` | Deliver via background queue; `false` delivers synchronously |
517
+ | `max_queue` | `Integer` | `100` | Max queued payloads before new ones are dropped (async) |
518
+ | `transport` | object / `nil` | `nil` | Custom transport (`post(path, payload)`); `nil` = `HttpTransport` |
519
+ | `open_timeout` | `Integer` | `2` | HTTP open timeout (seconds) |
520
+ | `read_timeout` | `Integer` | `3` | HTTP read timeout (seconds) |
521
+ | `session_cookie_name` | `String` | `"sentiero_sid"` | Cookie read for session-replay linkage |
522
+ | `window_cookie_name` | `String` | `"sentiero_wid"` | Cookie read for session-replay linkage |
523
+
524
+ Rails-only: `Sentiero::Rails.configure { |c| c.reporter_middleware = ... }` (default `true`) controls the middleware auto-install.
525
+
526
+ ## Production Checklist
527
+
528
+ Before deploying Sentiero to production, review each of these:
529
+
530
+ | Concern | Action |
531
+ |---------|--------|
532
+ | **HTTPS** | Always serve over HTTPS in production |
533
+ | **Dashboard auth** | Set `auth_callback`, without it, the dashboard is open to anyone |
534
+ | **Privacy compliance** | Review recording options for your jurisdiction; add `data-rr-block` to sensitive sections |
535
+ | **Encryption at rest** | Enable at the storage layer if recording sensitive pages |
536
+ | **Resource limits** | Set `max_events_per_request`, `max_sessions`, and `max_events_per_session` to prevent unbounded memory growth |
537
+ | **Rate limiting** | Add [Rack::Attack](https://github.com/rack/rack-attack) or nginx rate limiting on the events endpoint |
538
+ | **Store** | Use Redis with TTL or SQLite in production; Memory and File stores are for dev/small deployments |
539
+ | **CORS** | Set `cors_origins` to your frontend's origin(s) |
540
+
541
+ ## Storage Backends
542
+
543
+ ### Memory (dev/test)
544
+
545
+ ```ruby
546
+ config.store = Sentiero::Stores::Memory.new
547
+ ```
548
+
549
+ In-memory storage using `concurrent-ruby` thread-safe primitives. Data is lost on restart.
550
+
551
+ ### File (dev/small deployments)
552
+
553
+ ```ruby
554
+ require "sentiero/stores/file"
555
+
556
+ config.store = Sentiero::Stores::File.new(path: "tmp/sentiero_sessions")
557
+ ```
558
+
559
+ Stores sessions as JSON files on disk — one directory per session, one `.jsonl` file per window. Persists across restarts with zero dependencies. Not suitable for high-concurrency production use.
560
+
561
+ | Parameter | Type | Default | Description |
562
+ |-----------|------|---------|-------------|
563
+ | `path` | `String` | (required) | Directory path for session data |
564
+
565
+ ### SQLite (single-server production)
566
+
567
+ ```ruby
568
+ require "sentiero/stores/sqlite"
569
+
570
+ config.store = Sentiero::Stores::SQLite.new(path: "sentiero.db")
571
+ ```
572
+
573
+ Single-file database with WAL mode. Good for single-process production and small deployments without external services. Requires the `sqlite3` gem.
574
+
575
+ | Parameter | Type | Default | Description |
576
+ |-----------|------|---------|-------------|
577
+ | `path` | `String` | `"sentiero.db"` | Database file path (use `":memory:"` for in-memory) |
578
+
579
+ ### Redis (production)
580
+
581
+ ```ruby
582
+ require "sentiero/stores/redis"
583
+
584
+ config.store = Sentiero::Stores::Redis.new(
585
+ redis: Redis.new(url: "redis://localhost:6379/0"),
586
+ ttl: 86_400, # optional, seconds
587
+ prefix: "sentiero:" # optional, default: "sentiero:"
588
+ )
589
+ ```
590
+
591
+ Requires the `redis` gem.
592
+
593
+ | Parameter | Type | Default | Description |
594
+ |-----------|------|---------|-------------|
595
+ | `redis` | `::Redis` | (required) | Connected Redis client |
596
+ | `ttl` | `Integer` / `nil` | `nil` | TTL in seconds applied to all keys; `nil` = no expiry |
597
+ | `prefix` | `String` | `"sentiero:"` | Key namespace prefix |
598
+
599
+ ### Custom Store
600
+
601
+ Implement the methods defined in `Sentiero::Store` and verify with the shared contract tests.
602
+
603
+ See [the storage guide](https://sentiero.app/guide/storage/) for details on all backends, Redis data structures, and building custom stores.
604
+
605
+ ## Security
606
+
607
+ Sentiero has undergone multiple review passes covering XSS, CSRF, Redis key poisoning, gzip bomb attacks, payload-based OOM vectors, and other common vulnerability classes.
608
+
609
+ However, this is a new release and has not yet been battle-tested or reviewed by a 3rd party expert.
610
+
611
+ I can't obviously guarantee the absence of undiscovered vulnerabilities.
612
+
613
+ If you discover a security issue, please report it privately via [GitHub Security Advisories](https://github.com/stevegeek/sentiero/security/advisories/new), do not open a public issue. See [SECURITY.md](SECURITY.md) for full details, including a list of areas already reviewed.
614
+
615
+ ## Demo App
616
+
617
+ A Roda todo list app demonstrating full Sentiero integration with privacy features and dashboard auth.
618
+
619
+ ```bash
620
+ demo/run # Install deps + start server on :9292
621
+ demo/run help # See all commands
622
+ demo/run stop # Stop the server
623
+ ```
624
+
625
+ - **Todo app**: http://localhost:9292
626
+ - **Dashboard**: http://localhost:9292/sentiero/dashboard/ (credentials: `demo` / `demo`)
627
+ - Configurable via `demo/.env` (defaults) and `demo/.env.development` (local overrides)
628
+ - Set `REDIS_URL` in `.env.development` to use Redis; omit for File store (auto-fallback on connection failure, persists across restarts)
629
+
630
+ ## Development
631
+
632
+ ### Setup
633
+
634
+ ```bash
635
+ bin/dev up # Start Redis via Docker
636
+ bin/dev down # Stop services
637
+ bin/dev console # Open redis-cli
638
+ ```
639
+
640
+ ### Frontend Build
641
+
642
+ The recorder JS is pre-built and vendored in the gem. You only need this if modifying the frontend source:
643
+
644
+ ```bash
645
+ cd frontend && npm install && npm run build
646
+ # Output: lib/sentiero/web/assets/vendor/recorder.js
647
+ ```
648
+
649
+ See [the recorder guide](https://sentiero.app/guide/recorder/) for details on the frontend recorder's batching, compression, and retry behavior.
650
+
651
+ ### Testing
652
+
653
+ ```bash
654
+ bin/dev test # Start Redis + run all tests
655
+ bundle exec rake test # All tests (Redis must be running)
656
+ bundle exec rake test TEST=test/stores/memory_test.rb # Single file
657
+ ```
658
+
659
+ **Store contract tests** verify any store implementation against the required interface. Include `StoreContractTests` in your test class and implement `create_store`.
660
+
661
+ **Redis tests** skip automatically when Redis is unavailable.
662
+
663
+ ## Alternatives
664
+
665
+ Sentiero was initially inspired by [SpectatorSport](https://github.com/bensheldon/spectator_sport) by Ben Sheldon.
666
+
667
+ It was the first `rrweb` session recording gem I saw for Ruby, but I needed something but not bound to Rails.
668
+
669
+ Go check out `spectator_sport` too.
670
+
671
+ ## Thanks
672
+
673
+ Powered by [rrweb](https://www.rrweb.io/).
674
+
675
+ Coded by Claude Code and Stephen Ierodiaconou.
676
+
677
+ ## License
678
+
679
+ [MIT](LICENSE.txt) - Copyright 2026 Stephen Ierodiaconou