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,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