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.
- checksums.yaml +7 -0
- data/LICENSE.txt +7 -0
- data/README.md +679 -0
- data/lib/sentiero/analytics/analyzer.rb +91 -0
- data/lib/sentiero/analytics/bounded.rb +29 -0
- data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
- data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
- data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
- data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
- data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
- data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
- data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
- data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
- data/lib/sentiero/analytics/entry_attribution.rb +71 -0
- data/lib/sentiero/analytics/error_discovery.rb +118 -0
- data/lib/sentiero/analytics/events.rb +21 -0
- data/lib/sentiero/analytics/exporter.rb +242 -0
- data/lib/sentiero/analytics/form_analyzer.rb +153 -0
- data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
- data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
- data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
- data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
- data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
- data/lib/sentiero/analytics/problem_detail.rb +97 -0
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
- data/lib/sentiero/analytics/segmenter.rb +133 -0
- data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
- data/lib/sentiero/analytics/stats.rb +30 -0
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
- data/lib/sentiero/configuration.rb +184 -0
- data/lib/sentiero/erasure.rb +48 -0
- data/lib/sentiero/fingerprint.rb +34 -0
- data/lib/sentiero/ip_anonymizer.rb +29 -0
- data/lib/sentiero/redaction/config.rb +61 -0
- data/lib/sentiero/redaction.rb +207 -0
- data/lib/sentiero/reporter/configuration.rb +50 -0
- data/lib/sentiero/reporter/context.rb +31 -0
- data/lib/sentiero/reporter/dispatcher.rb +91 -0
- data/lib/sentiero/reporter/http_transport.rb +57 -0
- data/lib/sentiero/reporter/log_transport.rb +26 -0
- data/lib/sentiero/reporter/middleware.rb +62 -0
- data/lib/sentiero/reporter/normalizer.rb +14 -0
- data/lib/sentiero/reporter/null_transport.rb +18 -0
- data/lib/sentiero/reporter/report_context.rb +29 -0
- data/lib/sentiero/reporter/scrubber.rb +47 -0
- data/lib/sentiero/reporter/test_helper.rb +32 -0
- data/lib/sentiero/reporter/test_transport.rb +28 -0
- data/lib/sentiero/reporter.rb +214 -0
- data/lib/sentiero/roda.rb +47 -0
- data/lib/sentiero/store/error_store.rb +220 -0
- data/lib/sentiero/store/limits.rb +31 -0
- data/lib/sentiero/store/session_store.rb +118 -0
- data/lib/sentiero/store.rb +72 -0
- data/lib/sentiero/stores/file.rb +566 -0
- data/lib/sentiero/stores/memory.rb +362 -0
- data/lib/sentiero/stores/redis/keys.rb +59 -0
- data/lib/sentiero/stores/redis/lua.rb +119 -0
- data/lib/sentiero/stores/redis.rb +665 -0
- data/lib/sentiero/stores/sqlite/schema.rb +79 -0
- data/lib/sentiero/stores/sqlite.rb +626 -0
- data/lib/sentiero/user_agent.rb +32 -0
- data/lib/sentiero/version.rb +5 -0
- data/lib/sentiero/web/analytics_app.rb +538 -0
- data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
- data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
- data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
- data/lib/sentiero/web/assets/manifest.json +11 -0
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
- data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
- data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
- data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
- data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
- data/lib/sentiero/web/assets_app.rb +42 -0
- data/lib/sentiero/web/base_app.rb +319 -0
- data/lib/sentiero/web/basic_auth.rb +27 -0
- data/lib/sentiero/web/basic_auth_check.rb +41 -0
- data/lib/sentiero/web/body_reader.rb +44 -0
- data/lib/sentiero/web/csv_writer.rb +45 -0
- data/lib/sentiero/web/dashboard_app.rb +236 -0
- data/lib/sentiero/web/errors_app.rb +97 -0
- data/lib/sentiero/web/escaping.rb +37 -0
- data/lib/sentiero/web/events_app.rb +196 -0
- data/lib/sentiero/web/formatting.rb +43 -0
- data/lib/sentiero/web/ingest_app.rb +92 -0
- data/lib/sentiero/web/manifest.rb +43 -0
- data/lib/sentiero/web/monitoring_app.rb +316 -0
- data/lib/sentiero/web/script_tag.rb +57 -0
- data/lib/sentiero/web/shareable_replay.rb +88 -0
- data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
- data/lib/sentiero/web/templates/_brand.html.erb +18 -0
- data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
- data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
- data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
- data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
- data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
- data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
- data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
- data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
- data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
- data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
- data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
- data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
- data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
- data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
- data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
- data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
- data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
- data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
- data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
- data/lib/sentiero/web/templates/event_show.html.erb +52 -0
- data/lib/sentiero/web/templates/events_index.html.erb +177 -0
- data/lib/sentiero/web/templates/export_index.html.erb +69 -0
- data/lib/sentiero/web/templates/forms.html.erb +105 -0
- data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
- data/lib/sentiero/web/templates/import.html.erb +39 -0
- data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
- data/lib/sentiero/web/templates/segments.html.erb +114 -0
- data/lib/sentiero/web/templates/session_show.html.erb +195 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
- data/lib/sentiero/web/track_app.rb +57 -0
- data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
- data/lib/sentiero/web/views/analyzer_view.rb +27 -0
- data/lib/sentiero/web/views/base_view.rb +76 -0
- data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
- data/lib/sentiero/web/views/conversions_view.rb +41 -0
- data/lib/sentiero/web/views/engagement_view.rb +67 -0
- data/lib/sentiero/web/views/errors_index_view.rb +37 -0
- data/lib/sentiero/web/views/event_show_view.rb +20 -0
- data/lib/sentiero/web/views/events_index_view.rb +56 -0
- data/lib/sentiero/web/views/export_view.rb +23 -0
- data/lib/sentiero/web/views/forms_view.rb +28 -0
- data/lib/sentiero/web/views/frustration_view.rb +15 -0
- data/lib/sentiero/web/views/funnel_view.rb +36 -0
- data/lib/sentiero/web/views/heatmap_view.rb +34 -0
- data/lib/sentiero/web/views/import_view.rb +13 -0
- data/lib/sentiero/web/views/page_report_view.rb +43 -0
- data/lib/sentiero/web/views/problem_show_view.rb +46 -0
- data/lib/sentiero/web/views/scroll_view.rb +23 -0
- data/lib/sentiero/web/views/segments_view.rb +28 -0
- data/lib/sentiero/web/views/session_show_view.rb +105 -0
- data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
- data/lib/sentiero/web/views/vitals_view.rb +45 -0
- data/lib/sentiero/web/views.rb +24 -0
- data/lib/sentiero/window_ref.rb +6 -0
- data/lib/sentiero.rb +69 -0
- 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,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
|