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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Redaction
5
+ # Operator-facing redaction settings. The declarative subset serializes to
6
+ # the client (to_client_hash) and drives both engines; dom_patterns and
7
+ # server_proc are server-only.
8
+ class Config
9
+ attr_accessor :server_proc
10
+ attr_reader :url_mode, :disabled_patterns, :custom_patterns, :dom_patterns
11
+
12
+ URL_MODE_TO_CLIENT = {strip: "strip", keep_all: "keepAll", keep_filtered: "keepFiltered"}.freeze
13
+ URL_MODE_FROM_CLIENT = URL_MODE_TO_CLIENT.invert.freeze
14
+
15
+ def self.from_client_hash(hash)
16
+ hash ||= {}
17
+ new(
18
+ url_mode: URL_MODE_FROM_CLIENT.fetch(hash["urlMode"], :strip),
19
+ url_param_allowlist: hash["urlParamAllowlist"] || [],
20
+ url_param_denylist: hash["urlParamDenylist"] || [],
21
+ disabled_patterns: (hash["disabledPatterns"] || []).map(&:to_sym),
22
+ custom_patterns: (hash["customPatterns"] || []).map { |s| Regexp.new(s) }
23
+ )
24
+ end
25
+
26
+ def initialize(url_mode: :strip, url_param_allowlist: [], url_param_denylist: [], disabled_patterns: [], custom_patterns: [], dom_patterns: [], server_proc: nil)
27
+ @url_mode = url_mode
28
+ @url_param_allowlist = url_param_allowlist
29
+ @url_param_denylist = url_param_denylist
30
+ @disabled_patterns = disabled_patterns
31
+ @custom_patterns = custom_patterns
32
+ # Symbols so `TEXT_PATTERN_ORDER - dom_patterns` in redact_dom_event works
33
+ # even when an operator passes pattern names as strings.
34
+ @dom_patterns = dom_patterns.map(&:to_sym)
35
+ @server_proc = server_proc
36
+ end
37
+
38
+ def active_text_patterns
39
+ TEXT_PATTERN_ORDER - disabled_patterns
40
+ end
41
+
42
+ def effective_allowlist
43
+ @url_param_allowlist.map(&:downcase)
44
+ end
45
+
46
+ def effective_denylist
47
+ (BUILTIN_DENYLIST + @url_param_denylist.map(&:downcase)).uniq
48
+ end
49
+
50
+ def to_client_hash
51
+ {
52
+ urlMode: URL_MODE_TO_CLIENT.fetch(url_mode, "strip"),
53
+ urlParamAllowlist: effective_allowlist,
54
+ urlParamDenylist: effective_denylist,
55
+ disabledPatterns: disabled_patterns.map(&:to_s),
56
+ customPatterns: custom_patterns.map(&:source)
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "redaction/config"
5
+
6
+ module Sentiero
7
+ # Redaction engine for side-channel capture (navigation, form_submit,
8
+ # metadata, error, click) that bypasses rrweb input masking. Must stay
9
+ # byte-for-byte equivalent to the JS twin frontend/src/redaction.js;
10
+ # test/fixtures/redaction_cases.json pins that parity.
11
+ module Redaction
12
+ REDACTED = "[redacted]"
13
+
14
+ # Fixed application order, identical to the JS module.
15
+ TEXT_PATTERN_ORDER = %i[url jwt email long_hex card].freeze
16
+
17
+ TEXT_PATTERNS = {
18
+ jwt: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/,
19
+ email: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
20
+ long_hex: /\b[0-9a-fA-F]{32,}\b/,
21
+ card: /\b\d(?:[ -]?\d){12,18}\b/
22
+ }.freeze
23
+
24
+ URL_IN_TEXT = %r{https?://\S+}
25
+
26
+ BUILTIN_DENYLIST = %w[
27
+ token access_token refresh_token id_token password passwd pwd secret
28
+ api_key apikey key sig signature code auth session sessionid otp
29
+ ].freeze
30
+
31
+ CUSTOM_EVENT_TYPE = 5
32
+ META_EVENT_TYPE = 4
33
+
34
+ URL_METADATA_KEYS = %w[url referrer entry_url entry_referrer].freeze
35
+
36
+ # tag => { "field" => :url|:text }
37
+ CUSTOM_FIELD_MAP = {
38
+ "navigation" => {"url" => :url, "text" => :text},
39
+ "__form_submit" => {"url" => :url},
40
+ "error" => {"message" => :text, "stack" => :text, "source" => :url},
41
+ "__click" => {"selector" => :text}
42
+ }.freeze
43
+
44
+ module_function
45
+
46
+ def redact_url(url, config = Config.new)
47
+ return url unless url.is_a?(String)
48
+
49
+ case config.url_mode
50
+ when :keep_all then url
51
+ when :keep_filtered then filter_url(url, config)
52
+ else strip_url_string(url)
53
+ end
54
+ end
55
+
56
+ def redact_text(value, config = Config.new)
57
+ return value unless value.is_a?(String)
58
+
59
+ out = value
60
+ config.active_text_patterns.each do |name|
61
+ out = apply_text_pattern(out, name)
62
+ end
63
+ config.custom_patterns.each { |re| out = out.gsub(re, REDACTED) }
64
+ out
65
+ end
66
+
67
+ def redact_event(event, config = Config.new)
68
+ return event unless event.is_a?(Hash)
69
+
70
+ if event["type"] == CUSTOM_EVENT_TYPE && event["data"].is_a?(Hash)
71
+ redact_custom_event(event, config)
72
+ elsif event["type"] == META_EVENT_TYPE && event["data"].is_a?(Hash)
73
+ redact_meta_event(event, config)
74
+ else
75
+ redact_dom_event(event, config)
76
+ end
77
+ end
78
+
79
+ def redact_metadata(metadata, config = Config.new)
80
+ return metadata unless metadata.is_a?(Hash)
81
+
82
+ metadata.to_h do |key, value|
83
+ if URL_METADATA_KEYS.include?(key)
84
+ [key, redact_url(value, config)]
85
+ else
86
+ [key, deep_redact_strings(value, config)]
87
+ end
88
+ end
89
+ end
90
+
91
+ def apply_text_pattern(text, name)
92
+ if name == :url
93
+ text.gsub(URL_IN_TEXT) { |m| strip_url_string(m) }
94
+ else
95
+ text.gsub(TEXT_PATTERNS.fetch(name), REDACTED)
96
+ end
97
+ end
98
+
99
+ def filter_url(url, config)
100
+ base, query, frag = split_url(url)
101
+ pairs = query.empty? ? [] : query.split("&").filter_map { |p| filter_param(p, config) }
102
+ out = base
103
+ out += "?#{pairs.join("&")}" unless pairs.empty?
104
+ out += "##{redact_text(frag, config)}" unless frag.empty?
105
+ out
106
+ end
107
+
108
+ def filter_param(pair, config)
109
+ eq = pair.index("=")
110
+ name = (eq ? pair[0...eq] : pair).downcase
111
+ # Denylist wins over the allowlist so allowlisting a built-in secret name
112
+ # (token/password/...) can't re-enable persisting it.
113
+ return nil if config.effective_denylist.include?(name)
114
+ return pair if config.effective_allowlist.include?(name)
115
+ return pair unless eq
116
+
117
+ # Match patterns against the decoded value (email=user%40example.com must
118
+ # be caught the same as email=user@example.com) but only substitute when
119
+ # something actually matched; a clean survivor keeps its original,
120
+ # unmodified encoding rather than being needlessly re-encoded.
121
+ raw_value = pair[(eq + 1)..]
122
+ decoded = url_decode(raw_value)
123
+ redacted = redact_text(decoded, config)
124
+ (redacted == decoded) ? pair : "#{pair[0...eq]}=#{redacted}"
125
+ end
126
+
127
+ # Plain percent-decode (leaves "+" alone, unlike www-form decoding). Falls
128
+ # back to the raw value on malformed escapes or invalid UTF-8 rather than
129
+ # raising, since this parses attacker-controlled URLs from public events.
130
+ def url_decode(value)
131
+ decoded = URI::RFC2396_PARSER.unescape(value)
132
+ decoded.valid_encoding? ? decoded : value
133
+ end
134
+
135
+ # Manual split (not URI) so JS and Ruby behave identically on edge cases.
136
+ def split_url(url)
137
+ base = url
138
+ frag = ""
139
+ if (h = base.index("#"))
140
+ frag = base[(h + 1)..]
141
+ base = base[0...h]
142
+ end
143
+ query = ""
144
+ if (q = base.index("?"))
145
+ query = base[(q + 1)..]
146
+ base = base[0...q]
147
+ end
148
+ [base, query, frag]
149
+ end
150
+
151
+ def strip_url_string(url)
152
+ cut = url.index("?") || url.length
153
+ hash = url.index("#")
154
+ cut = hash if hash && hash < cut
155
+ url[0...cut]
156
+ end
157
+
158
+ def redact_custom_event(event, config)
159
+ map = CUSTOM_FIELD_MAP[event["data"]["tag"]]
160
+ payload = event["data"]["payload"]
161
+ return event unless payload.is_a?(Hash)
162
+
163
+ # Mapped fields use their url/text treatment; every other field (and every
164
+ # field of an unmapped tag) is deep-redacted rather than stored raw, so a
165
+ # buggy/hostile client can't smuggle PII through an unmapped key.
166
+ new_payload = payload.to_h do |k, v|
167
+ case map&.dig(k)
168
+ when :url then [k, redact_url(v, config)]
169
+ when :text then [k, redact_text(v, config)]
170
+ else [k, deep_redact_strings(v, config)]
171
+ end
172
+ end
173
+ event.merge("data" => event["data"].merge("payload" => new_payload))
174
+ end
175
+
176
+ # DOM text/data is left alone unless the operator opts in.
177
+ def redact_dom_event(event, config)
178
+ return event if config.dom_patterns.empty? && config.custom_patterns.empty?
179
+ return event unless event["data"]
180
+
181
+ dom_cfg = Config.new(disabled_patterns: TEXT_PATTERN_ORDER - config.dom_patterns,
182
+ custom_patterns: config.custom_patterns)
183
+ event.merge("data" => deep_redact_strings(event["data"], dom_cfg))
184
+ end
185
+
186
+ # rrweb Meta events (type 4) carry the full page URL in data.href, which
187
+ # bypasses rrweb's own input masking entirely; always URL-redact it like
188
+ # any other structural URL field (navigation.url, error.source, ...).
189
+ def redact_meta_event(event, config)
190
+ return event unless event["data"].key?("href")
191
+
192
+ event.merge("data" => event["data"].merge("href" => redact_url(event["data"]["href"], config)))
193
+ end
194
+
195
+ def deep_redact_strings(value, config)
196
+ case value
197
+ when String then redact_text(value, config)
198
+ when Array then value.map { |v| deep_redact_strings(v, config) }
199
+ when Hash
200
+ # Keys can carry PII too (e.g. a caller using an email as a hash key);
201
+ # redact them the same as values. Last-write-wins on key collisions.
202
+ value.to_h { |k, v| [deep_redact_strings(k, config), deep_redact_strings(v, config)] }
203
+ else value
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scrubber"
4
+
5
+ module Sentiero
6
+ module Reporter
7
+ class Configuration
8
+ attr_accessor :endpoint, :ingest_key, :project, :environment, :release,
9
+ :default_filter_keys, :filter_keys, :enabled, :async, :max_queue,
10
+ :open_timeout, :read_timeout,
11
+ :session_cookie_name, :window_cookie_name,
12
+ :transport,
13
+ :before_notify
14
+
15
+ attr_reader :ignore_exceptions
16
+
17
+ def initialize
18
+ @endpoint = nil
19
+ @ingest_key = nil
20
+ @project = nil
21
+ @environment = nil
22
+ @release = nil
23
+ @default_filter_keys = Scrubber::DEFAULT_KEYS.dup
24
+ @filter_keys = []
25
+ @enabled = true
26
+ @async = true
27
+ @max_queue = 100
28
+ @open_timeout = 2
29
+ @read_timeout = 3
30
+ @session_cookie_name = "sentiero_sid"
31
+ @window_cookie_name = "sentiero_wid"
32
+ @transport = nil
33
+ @ignore_exceptions = []
34
+ @before_notify = nil
35
+ end
36
+
37
+ def ignore_exceptions=(value)
38
+ @ignore_exceptions = Array(value)
39
+ end
40
+
41
+ def configured?
42
+ !endpoint.nil? && !ingest_key.nil? && !project.nil?
43
+ end
44
+
45
+ def active?
46
+ enabled && configured?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "normalizer"
4
+
5
+ module Sentiero
6
+ module Reporter
7
+ # Immutable, string-keyed bag of report context. Keys are normalized to
8
+ # strings on construction and on every merge.
9
+ class Context
10
+ def initialize(hash = {})
11
+ @data = Normalizer.stringify_shallow(hash).freeze
12
+ end
13
+
14
+ def merge(other)
15
+ Context.new(@data.merge(Normalizer.stringify_shallow(to_hash(other))))
16
+ end
17
+
18
+ def [](key) = @data[key.to_s]
19
+
20
+ def key?(key) = @data.key?(key.to_s)
21
+
22
+ def empty? = @data.empty?
23
+
24
+ def to_h = @data.dup
25
+
26
+ private
27
+
28
+ def to_hash(other) = other.is_a?(Context) ? other.to_h : other
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "concurrent/atomic/atomic_fixnum"
5
+
6
+ module Sentiero
7
+ module Reporter
8
+ # Delivers payloads to a transport, synchronously or via a bounded background
9
+ # queue. Never raises into the caller; when the async queue is full new
10
+ # payloads are dropped rather than blocking the host app.
11
+ class Dispatcher
12
+ # A latch pushed through the work queue by #flush; recognized by type.
13
+ FlushLatch = Thread::Queue
14
+
15
+ def dropped
16
+ @dropped.value
17
+ end
18
+
19
+ def initialize(transport, async:, max_queue:)
20
+ @transport = transport
21
+ @async = async
22
+ @dropped = Concurrent::AtomicFixnum.new(0) # incremented from concurrent enqueue callers
23
+ @rejection_warned = false
24
+ return unless @async
25
+
26
+ @queue = SizedQueue.new(max_queue)
27
+ @thread = Thread.new { run }
28
+ @thread.name = "sentiero-reporter" if @thread.respond_to?(:name=)
29
+ end
30
+
31
+ def enqueue(path, payload)
32
+ if @async
33
+ begin
34
+ @queue.push([path, payload], true) # non-block so ThreadError is raised if queue full
35
+ rescue ThreadError
36
+ @dropped.increment
37
+ end
38
+ else
39
+ deliver([path, payload])
40
+ end
41
+ nil
42
+ end
43
+
44
+ # Blocks until every payload enqueued before this call has been delivered.
45
+ # The latch rides the FIFO queue, so reaching it means all prior jobs are done.
46
+ def flush
47
+ return unless @async
48
+ latch = FlushLatch.new
49
+ @queue.push(latch)
50
+ latch.pop
51
+ nil
52
+ end
53
+
54
+ def shutdown
55
+ return unless @async
56
+ @queue.push(:stop)
57
+ @thread.join(2)
58
+ end
59
+
60
+ private
61
+
62
+ def run
63
+ loop do
64
+ job = @queue.pop
65
+ case job
66
+ when :stop then break
67
+ when FlushLatch then job.push(true) # prior jobs all delivered; wake #flush
68
+ else deliver(job)
69
+ end
70
+ end
71
+ end
72
+
73
+ def deliver((path, payload))
74
+ response = @transport.post(path, payload)
75
+ # Null/Log/Test transports return nil/arrays, not HTTP responses.
76
+ if response.respond_to?(:code) && !response.is_a?(Net::HTTPSuccess)
77
+ warn_rejected(response, path)
78
+ end
79
+ rescue => e
80
+ warn "[Sentiero::Reporter] delivery failed: #{e.class}: #{e.message}"
81
+ end
82
+
83
+ # First occurrence only (one dispatcher per process in practice.)
84
+ def warn_rejected(response, path)
85
+ return if @rejection_warned
86
+ @rejection_warned = true
87
+ warn "[Sentiero::Reporter] delivery rejected: HTTP #{response.code} for #{path}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Sentiero
8
+ module Reporter
9
+ # Posts a JSON payload to "<endpoint>/<path>" with the ingest key as a Bearer token.
10
+ class HttpTransport
11
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
12
+
13
+ def initialize(endpoint:, ingest_key:, open_timeout:, read_timeout:)
14
+ @endpoint = endpoint.to_s.sub(%r{/+\z}, "")
15
+ @ingest_key = ingest_key
16
+ @open_timeout = open_timeout
17
+ @read_timeout = read_timeout
18
+ warn_insecure_endpoint
19
+ end
20
+
21
+ def post(path, payload)
22
+ uri = URI.parse("#{@endpoint}/#{path}")
23
+ http = build_http(uri)
24
+
25
+ request = Net::HTTP::Post.new(uri)
26
+ request["content-type"] = "application/json"
27
+ request["authorization"] = "Bearer #{@ingest_key}"
28
+ request.body = JSON.generate(payload)
29
+
30
+ http.request(request)
31
+ end
32
+
33
+ private
34
+
35
+ def build_http(uri)
36
+ http = Net::HTTP.new(uri.host, uri.port)
37
+ http.use_ssl = (uri.scheme == "https")
38
+ http.open_timeout = @open_timeout
39
+ http.read_timeout = @read_timeout
40
+ http
41
+ end
42
+
43
+ # The Bearer ingest key and payloads go in cleartext over http://; warn
44
+ # unless the endpoint is loopback (a common local-dev setup).
45
+ def warn_insecure_endpoint
46
+ uri = URI.parse(@endpoint)
47
+ return unless uri.scheme == "http"
48
+ return if LOOPBACK_HOSTS.include?(uri.host)
49
+
50
+ warn "[Sentiero::Reporter] endpoint #{@endpoint} uses http://; the ingest " \
51
+ "key and payloads are sent unencrypted. Use https://."
52
+ rescue URI::InvalidURIError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sentiero
6
+ module Reporter
7
+ # Transport that logs each delivery instead of sending it over the network.
8
+ class LogTransport
9
+ def initialize(io: $stderr, logger: nil, level: :info)
10
+ @io = io
11
+ @logger = logger
12
+ @level = level
13
+ end
14
+
15
+ def post(path, payload)
16
+ line = "[Sentiero::Reporter] #{path}: #{JSON.generate(payload)}"
17
+ if @logger
18
+ @logger.public_send(@level, line)
19
+ else
20
+ @io.puts(line)
21
+ end
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+ require_relative "../reporter"
5
+ require_relative "../ip_anonymizer"
6
+
7
+ module Sentiero
8
+ module Reporter
9
+ # Rack middleware that reports unhandled exceptions to Sentiero and re-raises
10
+ # them so the host app's own error handling is unaffected. Reads the recorder's
11
+ # session/window id cookies into the context so server exceptions link to the replay.
12
+ class Middleware
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ Reporter.with_context(request_context(env)) do
19
+ @app.call(env)
20
+ rescue => e
21
+ Reporter.notify(e)
22
+ raise
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def request_context(env)
29
+ cookies = Rack::Utils.parse_cookies(env)
30
+ ctx = {
31
+ request: {
32
+ "method" => env["REQUEST_METHOD"],
33
+ "path" => env["PATH_INFO"],
34
+ "params" => safe_parse_query(env["QUERY_STRING"]),
35
+ "ip" => client_ip(env)
36
+ }
37
+ }
38
+ sid = cookies[Reporter.configuration.session_cookie_name]
39
+ wid = cookies[Reporter.configuration.window_cookie_name]
40
+ ctx[:session_id] = sid if sid && !sid.empty?
41
+ ctx[:window_id] = wid if wid && !wid.empty?
42
+ ctx
43
+ rescue => e
44
+ warn "[Sentiero::Reporter] request_context failed: #{e.class}: #{e.message}"
45
+ {}
46
+ end
47
+
48
+ def safe_parse_query(query_string)
49
+ Rack::Utils.parse_nested_query(query_string)
50
+ rescue => _e
51
+ {}
52
+ end
53
+
54
+ def client_ip(env)
55
+ forwarded = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip
56
+ ip = (forwarded && !forwarded.empty?) ? forwarded : env["REMOTE_ADDR"]
57
+ anonymize = Sentiero.respond_to?(:configuration) && Sentiero.configuration.anonymize_ip
58
+ anonymize ? IpAnonymizer.anonymize(ip) : ip
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Reporter
5
+ module Normalizer
6
+ module_function
7
+
8
+ def stringify_shallow(hash)
9
+ return {} unless hash.is_a?(Hash)
10
+ hash.transform_keys(&:to_s)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Reporter
5
+ class NullTransport
6
+ attr_reader :delivered
7
+
8
+ def initialize
9
+ @delivered = 0
10
+ end
11
+
12
+ def post(_path, _payload)
13
+ @delivered += 1
14
+ nil
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Reporter
5
+ # Splits a Context into the reserved keys that become top-level fields on an
6
+ # error report (session_id, window_id) and the remaining metadata that goes
7
+ # under the report's "context".
8
+ class ReportContext
9
+ RESERVED = %w[session_id window_id].freeze
10
+
11
+ def initialize(context)
12
+ data = context.to_h
13
+ @reserved = {}
14
+ RESERVED.each do |key|
15
+ value = data.delete(key)
16
+ @reserved[key] = value unless value.nil?
17
+ end
18
+ @metadata = data
19
+ end
20
+
21
+ def session_id = @reserved["session_id"]
22
+
23
+ def window_id = @reserved["window_id"]
24
+
25
+ # Mutable so the caller can inject environment/release before scrubbing.
26
+ attr_reader :metadata
27
+ end
28
+ end
29
+ end