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,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../redaction"
|
|
4
|
+
|
|
5
|
+
module Sentiero
|
|
6
|
+
module Reporter
|
|
7
|
+
# Replaces values whose key matches a sensitive pattern with "[FILTERED]",
|
|
8
|
+
# before data leaves the host app, so secrets never traverse the network.
|
|
9
|
+
# Matching is case-insensitive and substring based ("user_password" matches "password").
|
|
10
|
+
class Scrubber
|
|
11
|
+
FILTERED = "[FILTERED]"
|
|
12
|
+
# Superset of Redaction::BUILTIN_DENYLIST (the browser-lane URL param
|
|
13
|
+
# denylist) plus a few extras (credit card/SSN) the query-string lane
|
|
14
|
+
# doesn't need to cover. Keeping this as a union rather than a hand
|
|
15
|
+
# copy means the two lanes can't drift apart again.
|
|
16
|
+
DEFAULT_KEYS = (%w[
|
|
17
|
+
password passwd secret token api_key apikey authorization
|
|
18
|
+
access_token refresh_token secret_key private_key
|
|
19
|
+
credit_card card_number cvv ssn
|
|
20
|
+
] + Redaction::BUILTIN_DENYLIST).uniq.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(keys = DEFAULT_KEYS)
|
|
23
|
+
@patterns = Array(keys).map { |k| k.to_s.downcase }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scrub(obj)
|
|
27
|
+
case obj
|
|
28
|
+
when Hash
|
|
29
|
+
obj.each_with_object(obj.class.new) do |(k, v), acc|
|
|
30
|
+
acc[k] = sensitive?(k) ? FILTERED : scrub(v)
|
|
31
|
+
end
|
|
32
|
+
when Array
|
|
33
|
+
obj.map { |v| scrub(v) }
|
|
34
|
+
else
|
|
35
|
+
obj
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def sensitive?(key)
|
|
42
|
+
down = key.to_s.downcase
|
|
43
|
+
@patterns.any? { |pattern| down.include?(pattern) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_transport"
|
|
4
|
+
|
|
5
|
+
module Sentiero
|
|
6
|
+
module Reporter
|
|
7
|
+
# Test-suite support for asserting what the reporter would have sent.
|
|
8
|
+
# Not loaded by default: require "sentiero/reporter/test_helper".
|
|
9
|
+
module TestHelper
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
# Runs the block with a synchronous in-memory transport, restores the
|
|
13
|
+
# previous transport, and returns deliveries as [path, payload] pairs.
|
|
14
|
+
def capture_notifications
|
|
15
|
+
recorder = TestTransport.new
|
|
16
|
+
previous_transport = Reporter.configuration.transport
|
|
17
|
+
previous_async = Reporter.configuration.async
|
|
18
|
+
Reporter.configure do |c|
|
|
19
|
+
c.transport = recorder
|
|
20
|
+
c.async = false
|
|
21
|
+
end
|
|
22
|
+
yield
|
|
23
|
+
recorder.deliveries
|
|
24
|
+
ensure
|
|
25
|
+
Reporter.configure do |c|
|
|
26
|
+
c.transport = previous_transport
|
|
27
|
+
c.async = previous_async
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
module Reporter
|
|
5
|
+
# Transport that records every delivery in memory so host-app tests can
|
|
6
|
+
# assert what the reporter would have sent.
|
|
7
|
+
class TestTransport
|
|
8
|
+
attr_reader :deliveries
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@deliveries = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def post(path, payload)
|
|
15
|
+
@deliveries << [path, payload]
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def payloads_for(path)
|
|
20
|
+
@deliveries.select { |p, _| p == path }.map(&:last)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear
|
|
24
|
+
@deliveries.clear
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "redaction"
|
|
5
|
+
require_relative "reporter/configuration"
|
|
6
|
+
require_relative "reporter/normalizer"
|
|
7
|
+
require_relative "reporter/context"
|
|
8
|
+
require_relative "reporter/report_context"
|
|
9
|
+
require_relative "reporter/scrubber"
|
|
10
|
+
require_relative "reporter/dispatcher"
|
|
11
|
+
require_relative "reporter/http_transport"
|
|
12
|
+
require_relative "reporter/null_transport"
|
|
13
|
+
require_relative "reporter/log_transport"
|
|
14
|
+
require_relative "reporter/test_transport"
|
|
15
|
+
|
|
16
|
+
module Sentiero
|
|
17
|
+
# Client SDK for reporting exceptions and custom events to a (remote) Sentiero
|
|
18
|
+
# ingest. Every public method is fail-safe: it never raises into the host app.
|
|
19
|
+
module Reporter
|
|
20
|
+
# Guards lazy creation/teardown of the shared dispatcher (which spawns a
|
|
21
|
+
# background thread + queue) so a concurrent cold start can't build two.
|
|
22
|
+
RUNTIME_LOCK = Mutex.new
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield(configuration)
|
|
31
|
+
reset_runtime!
|
|
32
|
+
configuration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset!
|
|
36
|
+
RUNTIME_LOCK.synchronize do
|
|
37
|
+
shutdown
|
|
38
|
+
@configuration = Configuration.new
|
|
39
|
+
@dispatcher = nil
|
|
40
|
+
@scrubber = nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notify(exception, context: {})
|
|
45
|
+
return unless configuration.active?
|
|
46
|
+
return if ignored?(exception)
|
|
47
|
+
|
|
48
|
+
payload = run_before_notify(build_error_payload(exception, build_report_context(context)))
|
|
49
|
+
return if payload.nil?
|
|
50
|
+
|
|
51
|
+
dispatcher.enqueue("errors", payload)
|
|
52
|
+
nil
|
|
53
|
+
rescue => e
|
|
54
|
+
warn "[Sentiero::Reporter] notify failed: #{e.class}: #{e.message}"
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def track(name, level: "info", session_id: nil, **payload)
|
|
59
|
+
return unless configuration.active?
|
|
60
|
+
|
|
61
|
+
dispatcher.enqueue("track", build_track_event(name, level, session_id, payload))
|
|
62
|
+
nil
|
|
63
|
+
rescue => e
|
|
64
|
+
warn "[Sentiero::Reporter] track failed: #{e.class}: #{e.message}"
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Per-thread context. Stored as a Context (string-keyed by construction)
|
|
69
|
+
# so readback is consistently string-keyed.
|
|
70
|
+
def context
|
|
71
|
+
context_store.to_h
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_context(hash)
|
|
75
|
+
self.fiber_local_context = context_store.merge(hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def with_context(hash)
|
|
79
|
+
previous = context_store
|
|
80
|
+
self.fiber_local_context = context_store.merge(hash)
|
|
81
|
+
yield
|
|
82
|
+
ensure
|
|
83
|
+
self.fiber_local_context = previous
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def clear_context
|
|
87
|
+
self.fiber_local_context = Context.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def flush
|
|
91
|
+
@dispatcher&.flush
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def shutdown
|
|
95
|
+
@dispatcher&.shutdown
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def context_key
|
|
101
|
+
:sentiero_reporter_context
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fiber_local_context
|
|
105
|
+
Thread.current[context_key]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def fiber_local_context=(value)
|
|
109
|
+
Thread.current[context_key] = value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def context_store
|
|
113
|
+
self.fiber_local_context ||= Context.new
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ignore_exceptions entries are matched as Class (is_a?) or String class-name.
|
|
117
|
+
def ignored?(exception)
|
|
118
|
+
configuration.ignore_exceptions.any? do |matcher|
|
|
119
|
+
case matcher
|
|
120
|
+
when Module
|
|
121
|
+
exception.is_a?(matcher)
|
|
122
|
+
when String
|
|
123
|
+
exception.class.ancestors.any? { |a| a.name == matcher }
|
|
124
|
+
else
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
rescue => e
|
|
129
|
+
warn "[Sentiero::Reporter] ignore_exceptions check failed: #{e.class}: #{e.message}"
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_report_context(context)
|
|
134
|
+
report_ctx = ReportContext.new(context_store.merge(context))
|
|
135
|
+
meta = report_ctx.metadata
|
|
136
|
+
meta["environment"] = configuration.environment if configuration.environment
|
|
137
|
+
meta["release"] = configuration.release if configuration.release
|
|
138
|
+
report_ctx
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_error_payload(exception, report_ctx)
|
|
142
|
+
config = redaction_config
|
|
143
|
+
payload = {
|
|
144
|
+
"exception_class" => exception.class.name,
|
|
145
|
+
"message" => Redaction.redact_text(exception.message.to_s, config),
|
|
146
|
+
"backtrace" => Array(exception.backtrace).map { |frame| Redaction.redact_text(frame.to_s, config) },
|
|
147
|
+
"context" => Redaction.deep_redact_strings(scrubber.scrub(report_ctx.metadata), config),
|
|
148
|
+
"timestamp" => Time.now.to_f
|
|
149
|
+
}
|
|
150
|
+
payload["session_id"] = report_ctx.session_id if report_ctx.session_id
|
|
151
|
+
payload["window_id"] = report_ctx.window_id if report_ctx.window_id
|
|
152
|
+
payload
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# An explicit session_id wins; otherwise fall back to the thread context.
|
|
156
|
+
def build_track_event(name, level, session_id, payload)
|
|
157
|
+
scrubbed = scrubber.scrub(Normalizer.stringify_shallow(payload))
|
|
158
|
+
event = {
|
|
159
|
+
"name" => name.to_s,
|
|
160
|
+
"level" => level.to_s,
|
|
161
|
+
"payload" => Redaction.deep_redact_strings(scrubbed, redaction_config),
|
|
162
|
+
"timestamp" => Time.now.to_f
|
|
163
|
+
}
|
|
164
|
+
session_id ||= context_store["session_id"]
|
|
165
|
+
event["session_id"] = session_id if session_id
|
|
166
|
+
event
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Returns the (possibly mutated) report, or nil to drop it when the hook
|
|
170
|
+
# returns false/nil.
|
|
171
|
+
def run_before_notify(payload)
|
|
172
|
+
hook = configuration.before_notify
|
|
173
|
+
return payload unless hook
|
|
174
|
+
|
|
175
|
+
result = hook.call(payload)
|
|
176
|
+
return if result == false || result.nil?
|
|
177
|
+
result.is_a?(Hash) ? result : payload
|
|
178
|
+
rescue => e
|
|
179
|
+
warn "[Sentiero::Reporter] before_notify failed: #{e.class}: #{e.message}"
|
|
180
|
+
payload
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def scrubber
|
|
184
|
+
@scrubber || RUNTIME_LOCK.synchronize { @scrubber ||= Scrubber.new(configuration.default_filter_keys + configuration.filter_keys) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Defaults when core isn't loaded, so a standalone reporter client still redacts.
|
|
188
|
+
def redaction_config
|
|
189
|
+
Sentiero.respond_to?(:configuration) ? Sentiero.configuration.redaction : Redaction::Config.new
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def dispatcher
|
|
193
|
+
@dispatcher || RUNTIME_LOCK.synchronize { @dispatcher ||= Dispatcher.new(transport, async: configuration.async, max_queue: configuration.max_queue) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def transport
|
|
197
|
+
configuration.transport || HttpTransport.new(
|
|
198
|
+
endpoint: configuration.endpoint,
|
|
199
|
+
ingest_key: configuration.ingest_key,
|
|
200
|
+
open_timeout: configuration.open_timeout,
|
|
201
|
+
read_timeout: configuration.read_timeout
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def reset_runtime!
|
|
206
|
+
RUNTIME_LOCK.synchronize do
|
|
207
|
+
shutdown
|
|
208
|
+
@dispatcher = nil
|
|
209
|
+
@scrubber = nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sentiero"
|
|
4
|
+
|
|
5
|
+
class Roda
|
|
6
|
+
module RodaPlugins
|
|
7
|
+
module Sentiero
|
|
8
|
+
def self.configure(_app, **opts)
|
|
9
|
+
config = ::Sentiero.configuration
|
|
10
|
+
opts.each do |key, value|
|
|
11
|
+
setter = :"#{key}="
|
|
12
|
+
config.public_send(setter, value) if config.respond_to?(setter)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module RequestMethods
|
|
17
|
+
def sentiero_events
|
|
18
|
+
run ::Sentiero::Web::EventsApp.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sentiero_assets
|
|
22
|
+
run ::Sentiero::Web::AssetsApp.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sentiero_dashboard
|
|
26
|
+
run ::Sentiero::Web::DashboardApp.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sentiero_analytics
|
|
30
|
+
run ::Sentiero::Web::AnalyticsApp.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sentiero_monitoring
|
|
34
|
+
run ::Sentiero::Web::MonitoringApp.new
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module InstanceMethods
|
|
39
|
+
def sentiero_script_tag(events_url:, recorder_url: nil)
|
|
40
|
+
::Sentiero::Web::ScriptTag.render(events_url: events_url, recorder_url: recorder_url)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
register_plugin(:sentiero, Sentiero)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
class Store
|
|
5
|
+
# The error-tracking store contract: problems, occurrences, and server events.
|
|
6
|
+
#
|
|
7
|
+
# Keying convention: raw stored records (occurrences, server events) are
|
|
8
|
+
# string-keyed Hashes, exactly as they arrived from JSON; computed
|
|
9
|
+
# summaries (problems) are symbol-keyed.
|
|
10
|
+
module ErrorStore
|
|
11
|
+
# Records the occurrence and upserts its Problem (keyed by "fingerprint"):
|
|
12
|
+
# bump count, extend last_seen, preserve first_seen, refresh message,
|
|
13
|
+
# reopen if "resolved". Returns the problem id (== fingerprint).
|
|
14
|
+
def save_occurrence(occurrence)
|
|
15
|
+
raise NoMethodError, "#{self.class}#save_occurrence not implemented"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# since/until_time (epoch seconds) bound the listing by each problem's
|
|
19
|
+
# last_seen, inclusive on both ends.
|
|
20
|
+
def list_problems(project:, limit:, offset: 0, status: nil, sort_by: nil, search: nil, since: nil, until_time: nil)
|
|
21
|
+
raise NoMethodError, "#{self.class}#list_problems not implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the symbol-keyed problem summary, or nil when unknown.
|
|
25
|
+
def get_problem(problem_id)
|
|
26
|
+
raise NoMethodError, "#{self.class}#get_problem not implemented"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns string-keyed occurrence records as stored (plus assigned "id"),
|
|
30
|
+
# ascending by timestamp; `after` is an exclusive timestamp cursor.
|
|
31
|
+
def get_occurrences(problem_id, after: nil, limit: nil)
|
|
32
|
+
raise NoMethodError, "#{self.class}#get_occurrences not implemented"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Default materializes rows via get_occurrences so custom stores keep
|
|
36
|
+
# working; built-in backends override with direct counts.
|
|
37
|
+
def count_occurrences(problem_id, after: nil)
|
|
38
|
+
get_occurrences(problem_id, after: after).size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def update_problem_status(problem_id, status)
|
|
42
|
+
raise NoMethodError, "#{self.class}#update_problem_status not implemented"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def save_server_event(event)
|
|
46
|
+
raise NoMethodError, "#{self.class}#save_server_event not implemented"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns string-keyed server-event records as stored (plus assigned
|
|
50
|
+
# "id"), ascending by timestamp; `after` is an exclusive timestamp cursor.
|
|
51
|
+
def list_server_events(project:, limit:, name: nil, level: nil, session_id: nil, after: nil)
|
|
52
|
+
raise NoMethodError, "#{self.class}#list_server_events not implemented"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_server_event(event_id)
|
|
56
|
+
raise NoMethodError, "#{self.class}#get_server_event not implemented"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def occurrences_for_session(session_id, limit: nil)
|
|
60
|
+
raise NoMethodError, "#{self.class}#occurrences_for_session not implemented"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def server_events_for_session(session_id, limit: nil)
|
|
64
|
+
raise NoMethodError, "#{self.class}#server_events_for_session not implemented"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def session_ids_for_problem(problem_id, limit: nil)
|
|
68
|
+
raise NoMethodError, "#{self.class}#session_ids_for_problem not implemented"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Shared problem bookkeeping: the semantic source of truth for problem
|
|
74
|
+
# lifecycle rules, which SQLite and the Rails store re-express natively.
|
|
75
|
+
|
|
76
|
+
# The list_problems filter/sort/paginate pipeline over symbol-keyed problem
|
|
77
|
+
# hashes; since/until_time bounds on last_seen are inclusive. Returns dups
|
|
78
|
+
# so callers can't mutate stored problems through the result.
|
|
79
|
+
def filter_and_page_problems(items, project:, status:, since:, until_time:, search:, sort_by:, offset:, limit:)
|
|
80
|
+
items = items.select { |p| p[:project] == project } unless project.nil?
|
|
81
|
+
items = items.select { |p| p[:status] == status } if status
|
|
82
|
+
items = items.select { |p| p[:last_seen] >= since.to_f } if since
|
|
83
|
+
items = items.select { |p| p[:last_seen] <= until_time.to_f } if until_time
|
|
84
|
+
if search && !search.empty?
|
|
85
|
+
term = search.downcase
|
|
86
|
+
items = items.select { |p|
|
|
87
|
+
p[:title].downcase.include?(term) || p[:exception_class].downcase.include?(term)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
items = case sort_by
|
|
91
|
+
when "first_seen" then items.sort_by { |p| -p[:first_seen] }
|
|
92
|
+
when "count" then items.sort_by { |p| -p[:count] }
|
|
93
|
+
else items.sort_by { |p| -p[:last_seen] }
|
|
94
|
+
end
|
|
95
|
+
(items.slice(offset, limit) || []).map(&:dup)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def new_problem_attrs(occurrence, ts)
|
|
99
|
+
{
|
|
100
|
+
id: occurrence["fingerprint"],
|
|
101
|
+
project: occurrence["project"],
|
|
102
|
+
exception_class: occurrence["exception_class"],
|
|
103
|
+
title: build_problem_title(occurrence),
|
|
104
|
+
message: occurrence["message"],
|
|
105
|
+
count: 1,
|
|
106
|
+
status: "open",
|
|
107
|
+
first_seen: ts,
|
|
108
|
+
last_seen: ts,
|
|
109
|
+
resolved_at: nil
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Upsert rule for a repeat occurrence: reopens the problem if resolved.
|
|
114
|
+
def touched_problem_attrs(existing, occurrence, ts)
|
|
115
|
+
reopening = existing[:status] == "resolved"
|
|
116
|
+
existing.merge(
|
|
117
|
+
count: existing[:count] + 1,
|
|
118
|
+
first_seen: [existing[:first_seen], ts].min,
|
|
119
|
+
last_seen: [existing[:last_seen], ts].max,
|
|
120
|
+
message: occurrence["message"],
|
|
121
|
+
status: reopening ? "open" : existing[:status],
|
|
122
|
+
resolved_at: reopening ? nil : existing[:resolved_at]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_problem_title(occurrence)
|
|
127
|
+
"#{occurrence["exception_class"]}: #{occurrence["message"]}"[0, PROBLEM_TITLE_MAX]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Maps a string-keyed stored problem record to the symbol-keyed shape.
|
|
131
|
+
def problem_from_strings(h)
|
|
132
|
+
{
|
|
133
|
+
id: h["id"],
|
|
134
|
+
project: h["project"],
|
|
135
|
+
exception_class: h["exception_class"],
|
|
136
|
+
title: h["title"],
|
|
137
|
+
message: h["message"],
|
|
138
|
+
count: h["count"],
|
|
139
|
+
status: h["status"],
|
|
140
|
+
first_seen: h["first_seen"],
|
|
141
|
+
last_seen: h["last_seen"],
|
|
142
|
+
resolved_at: h["resolved_at"]
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Shared in-memory error-data read/purge layer for backends holding whole
|
|
147
|
+
# collections in Ruby (Memory, File, Redis). The mutating helpers
|
|
148
|
+
# (!-suffixed) must receive the live collections, inside whatever
|
|
149
|
+
# synchronization the caller owns. Works on plain Hash/Array and their
|
|
150
|
+
# concurrent-ruby counterparts.
|
|
151
|
+
|
|
152
|
+
# list_server_events filter pipeline; `after` is an exclusive cursor.
|
|
153
|
+
def filter_server_events(events, project:, name:, level:, session_id:, after:, limit:)
|
|
154
|
+
items = events
|
|
155
|
+
items = items.select { |e| e["project"] == project } unless project.nil?
|
|
156
|
+
items = items.select { |e| e["name"] == name } if name
|
|
157
|
+
items = items.select { |e| e["level"] == level } if level
|
|
158
|
+
items = items.select { |e| e["session_id"] == session_id } if session_id
|
|
159
|
+
items = items.select { |e| e["timestamp"].to_f > after.to_f } if after
|
|
160
|
+
items = items.sort_by { |e| e["timestamp"].to_f }
|
|
161
|
+
items.first(limit)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def rows_for_session(rows, session_id, limit:)
|
|
165
|
+
result = rows
|
|
166
|
+
.select { |row| row["session_id"] == session_id }
|
|
167
|
+
.sort_by { |row| row["timestamp"].to_f }
|
|
168
|
+
limit ? result.first(limit) : result
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Distinct session ids across an occurrence list, most recently seen first
|
|
172
|
+
# (by each session's latest occurrence). Occurrences without a session_id
|
|
173
|
+
# are skipped.
|
|
174
|
+
def latest_session_ids(occurrences, limit:)
|
|
175
|
+
latest_by_session = {}
|
|
176
|
+
occurrences.each do |occ|
|
|
177
|
+
sid = occ["session_id"]
|
|
178
|
+
next unless sid
|
|
179
|
+
ts = occ["timestamp"].to_f
|
|
180
|
+
latest_by_session[sid] = [latest_by_session[sid] || ts, ts].max
|
|
181
|
+
end
|
|
182
|
+
ids = latest_by_session.sort_by { |_sid, ts| -ts }.map(&:first)
|
|
183
|
+
limit ? ids.first(limit) : ids
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Ages out error data older than the cutoff, in place: server events and
|
|
187
|
+
# occurrence rows by timestamp, then problems whose last_seen is stale
|
|
188
|
+
# (along with their remaining occurrences).
|
|
189
|
+
def purge_error_collections!(problems, occurrences, server_events, cutoff)
|
|
190
|
+
server_events.reject! { |event| event["timestamp"].to_f < cutoff }
|
|
191
|
+
|
|
192
|
+
occurrences.each_pair do |_fp, list|
|
|
193
|
+
list.reject! { |occ| occ["timestamp"].to_f < cutoff }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
stale_fps = problems.each_pair.filter_map { |fp, problem| fp if problem[:last_seen] < cutoff }
|
|
197
|
+
stale_fps.each do |fp|
|
|
198
|
+
problems.delete(fp)
|
|
199
|
+
occurrences.delete(fp)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Evicts the least-recently-seen problems (and their occurrences), in
|
|
204
|
+
# place, until at most `max` remain. No-op when max is nil.
|
|
205
|
+
def evict_oldest_problems!(problems, occurrences, max)
|
|
206
|
+
return unless max && problems.size > max
|
|
207
|
+
|
|
208
|
+
to_evict = problems.size - max
|
|
209
|
+
oldest_fps = problems.each_pair
|
|
210
|
+
.sort_by { |_fp, problem| problem[:last_seen] }
|
|
211
|
+
.first(to_evict)
|
|
212
|
+
.map(&:first)
|
|
213
|
+
oldest_fps.each do |fp|
|
|
214
|
+
problems.delete(fp)
|
|
215
|
+
occurrences.delete(fp)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
class Store
|
|
5
|
+
# Eviction/scan caps for a store, held by the store itself instead of read
|
|
6
|
+
# from the global Sentiero.configuration. Build one explicitly, or derive the
|
|
7
|
+
# configured defaults at the composition root with .from_configuration.
|
|
8
|
+
class Limits
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
max_events_per_session: nil,
|
|
11
|
+
max_sessions: nil,
|
|
12
|
+
max_problems: 5_000,
|
|
13
|
+
max_server_events: 50_000,
|
|
14
|
+
analytics_max_scan_sessions: 5_000
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.from_configuration(config = Sentiero.configuration)
|
|
18
|
+
new(**DEFAULTS.keys.to_h { |attr| [attr, config.public_send(attr)] })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader(*DEFAULTS.keys)
|
|
22
|
+
|
|
23
|
+
def initialize(**overrides)
|
|
24
|
+
unknown = overrides.keys - DEFAULTS.keys
|
|
25
|
+
raise ArgumentError, "unknown limit(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
26
|
+
|
|
27
|
+
DEFAULTS.merge(overrides).each { |attr, value| instance_variable_set(:"@#{attr}", value) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|