fractal-ruby 0.1.0

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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Delivery health shared by the error reporter and analytics: one circuit breaker, because both
5
+ # talk to the same collector with the same key, and one revocation flag, because a 401 means the
6
+ # key itself is bad.
7
+ #
8
+ # Breaker: FAILURE_THRESHOLD consecutive failed POSTs (from either half) open it for COOLDOWN
9
+ # seconds, then one probe is let through. A failed probe leaves the count at the threshold, so it
10
+ # reopens at once; any success resets it.
11
+ #
12
+ # Revocation is for the life of the process (ANALYTICS_PROTOCOL.md, "401"): a revoked key is not
13
+ # retried once a second forever. It survives a fork, since the child holds the same key.
14
+ class Breaker
15
+ FAILURE_THRESHOLD = 5
16
+ COOLDOWN = 60
17
+
18
+ def initialize(clock: nil)
19
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
20
+ @revoked = false
21
+ reset
22
+ end
23
+
24
+ def open?
25
+ reset_after_fork
26
+ @mutex.synchronize { !@opened_at.nil? && now - @opened_at < COOLDOWN }
27
+ end
28
+
29
+ def revoked?
30
+ @revoked
31
+ end
32
+
33
+ def success!
34
+ reset_after_fork
35
+ @mutex.synchronize do
36
+ @consecutive_failures = 0
37
+ @opened_at = nil
38
+ end
39
+ end
40
+
41
+ def failure!
42
+ reset_after_fork
43
+ @mutex.synchronize do
44
+ @consecutive_failures += 1
45
+ @opened_at = now if @consecutive_failures >= FAILURE_THRESHOLD
46
+ end
47
+ end
48
+
49
+ def revoke!
50
+ @revoked = true
51
+ end
52
+
53
+ private
54
+
55
+ def now = @clock.call
56
+
57
+ def reset
58
+ @pid = Process.pid
59
+ @mutex = Mutex.new
60
+ @consecutive_failures = 0
61
+ @opened_at = nil
62
+ end
63
+
64
+ # A mutex inherited across fork may be held by a thread that does not exist in the child.
65
+ def reset_after_fork
66
+ reset unless @pid == Process.pid
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # One configured instance: the two subsystems, sharing a transport and a breaker. A subsystem
5
+ # turned off in the configuration (or with no API key at all) is a NullErrors / NullAnalytics,
6
+ # whose methods do nothing, so calling code never checks for nil.
7
+ class Client
8
+ attr_reader :config, :errors, :analytics, :breaker
9
+
10
+ def initialize(config, transport: Transport.new, breaker: Breaker.new, start_workers: true)
11
+ @config = config
12
+ @breaker = breaker
13
+ on = config.api_key?
14
+ reporter = ErrorReporter.new(config: config, transport: transport, breaker: breaker, start_worker: start_workers)
15
+ @errors = on && config.errors_enabled ? Errors.new(reporter) : NullErrors.new
16
+ @analytics = on && config.analytics_enabled ? Analytics.new(config: config, transport: transport, breaker: breaker, start_worker: start_workers) : NullAnalytics.new
17
+ end
18
+
19
+ def flush
20
+ @errors.flush
21
+ @analytics.flush
22
+ end
23
+
24
+ def shutdown
25
+ @errors.shutdown
26
+ @analytics.shutdown
27
+ end
28
+
29
+ # at_exit runs in every forked child too; one registration covers all of them, and it always
30
+ # shuts down whichever client is active at exit.
31
+ def register_at_exit
32
+ return if Client.instance_variable_get(:@at_exit_registered)
33
+
34
+ Client.instance_variable_set(:@at_exit_registered, true)
35
+ at_exit { Fractal.shutdown }
36
+ end
37
+ end
38
+
39
+ # Fractal.errors when error reporting is on.
40
+ class Errors
41
+ attr_reader :reporter
42
+
43
+ def initialize(reporter)
44
+ @reporter = reporter
45
+ end
46
+
47
+ # Reports an exception you rescued. Never raises.
48
+ def capture_error(exception, handled: true, severity: :error, context: {})
49
+ @reporter.report(exception, handled: handled, severity: severity, source: "fractal.capture_error",
50
+ context: ExecutionContext.current.merge(context))
51
+ end
52
+
53
+ # Reports a message with no exception; the stack is where this was called from.
54
+ def capture_message(message, severity: :info, context: {})
55
+ @reporter.report_message(message, severity: severity, frames: caller(1),
56
+ context: ExecutionContext.current.merge(context))
57
+ end
58
+
59
+ # Rails.error subscriber entry point.
60
+ def report(exception, handled:, severity:, source:, context: {})
61
+ @reporter.report(exception, handled: handled, severity: severity, source: source, context: context)
62
+ end
63
+
64
+ def enabled? = @reporter.enabled?
65
+ def flush(...) = @reporter.flush(...)
66
+ def shutdown(...) = @reporter.shutdown(...)
67
+ def stats = @reporter.stats
68
+ end
69
+
70
+ class NullErrors
71
+ def capture_error(*, **) = nil
72
+ def capture_message(*, **) = nil
73
+ def report(*, **) = nil
74
+ def enabled? = false
75
+ def flush(...) = nil
76
+ def shutdown(...) = nil
77
+ def stats = {}
78
+ end
79
+
80
+ class NullAnalytics
81
+ def track(*, **) = false
82
+ def identify(*, **) = false
83
+ def enabled? = false
84
+ def flush(...) = nil
85
+ def shutdown(...) = nil
86
+ def stats = {}
87
+ def pending = 0
88
+ end
89
+
90
+ # Before Fractal.configure: everything is a no-op.
91
+ class NullClient
92
+ attr_reader :config, :errors, :analytics
93
+
94
+ def initialize
95
+ @config = Configuration.new
96
+ @errors = NullErrors.new
97
+ @analytics = NullAnalytics.new
98
+ end
99
+
100
+ def flush = nil
101
+ def shutdown = nil
102
+ def register_at_exit = nil
103
+ end
104
+
105
+ NULL_CLIENT = NullClient.new
106
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Everything an app can set in `Fractal.configure`. Values that default to something Rails knows
5
+ # (filter_parameters, root, environment, logger) are read lazily, on first use, so an initializer
6
+ # that runs before Rails has finished booting still gets them.
7
+ class Configuration
8
+ DEFAULT_COLLECTOR_URL = "https://collector.getfractal.dev"
9
+
10
+ # Rails' own default config/initializers/filter_parameter_logging.rb list, used when the app
11
+ # is not a Rails app or Rails has no filter_parameters configured.
12
+ DEFAULT_FILTER_PARAMETERS = %w[passw email secret token _key crypt salt certificate otp ssn cvv cvc].freeze
13
+
14
+ # The API key of a Fractal project. Use a key created for this backend, not the one the game
15
+ # client ships with. Without one, the gem does nothing.
16
+ attr_accessor :api_key
17
+ attr_accessor :collector_url
18
+ # The app's own version. Fractal resolves and reopens error groups by comparing versions, so
19
+ # an empty value is accepted with a warning but makes both unreliable.
20
+ attr_accessor :app_version
21
+ attr_writer :environment
22
+
23
+ # true, false or a callable checked on every call, so a kill switch read from ENV takes
24
+ # effect without a restart. It is process-wide and also read on the sending threads, where
25
+ # there is no request: turning it off discards what is buffered, so it is no place for a
26
+ # per-user check such as consent (that belongs at the track call site).
27
+ attr_accessor :enabled
28
+ attr_accessor :errors_enabled, :analytics_enabled
29
+
30
+ # ->(controller) { "user-42" or nil }: the player id for the signed-in user of a request.
31
+ # Consulted when nothing called Fractal.set_user (or the Warden hook) in this request.
32
+ attr_accessor :user_resolver
33
+ # ->(user) { id string }: how a Warden/Devise user becomes a player id.
34
+ attr_accessor :player_id_for_user
35
+ # Player ids for traffic with no user: a request nobody is signed in for, and code outside
36
+ # any request (jobs, rake tasks, boot).
37
+ attr_accessor :anonymous_player_id, :system_player_id
38
+ # ->(rack_env) { token or nil }: the session an analytics event belongs to.
39
+ attr_accessor :session_resolver
40
+ # Send each analytics event's end-user address so the collector can geolocate it. The
41
+ # collector only uses it when the project has IP tracking on.
42
+ attr_accessor :send_client_ip
43
+ # Track an `identify` event when Warden signs a user in, linking the pre-login session to
44
+ # the user.
45
+ attr_accessor :identify_on_login
46
+
47
+ attr_writer :filter_parameters, :project_root, :logger
48
+ # Extra keys to mask, on top of filter_parameters and the gem's own list.
49
+ attr_accessor :extra_filter_keys
50
+ # Callables ->(exception, raw_message) { replacement or nil }, tried in order before the
51
+ # gem's built-in database rules. The first non-nil answer is used (then scrubbed as usual).
52
+ attr_accessor :message_filters
53
+ # ->(event_hash) { event_hash or nil }: a last look at each error event; nil drops it.
54
+ attr_accessor :before_send_error
55
+
56
+ # Raise ArgumentError from `track` on an event the collector would reject, instead of
57
+ # logging and dropping it. Defaults to on outside production.
58
+ attr_writer :strict
59
+
60
+ attr_accessor :errors_flush_interval, :analytics_flush_interval, :analytics_flush_at
61
+ attr_accessor :analytics_max_queue, :analytics_max_queue_bytes, :shutdown_timeout
62
+
63
+ def initialize
64
+ @collector_url = DEFAULT_COLLECTOR_URL
65
+ @app_version = ""
66
+ @enabled = true
67
+ @errors_enabled = true
68
+ @analytics_enabled = true
69
+ @anonymous_player_id = "anonymous"
70
+ @system_player_id = "system"
71
+ @player_id_for_user = ->(user) { user.respond_to?(:id) ? user.id.to_s : user.to_s }
72
+ @session_resolver = SessionResolver.method(:from_rack_session)
73
+ @send_client_ip = true
74
+ @identify_on_login = true
75
+ @extra_filter_keys = []
76
+ @message_filters = []
77
+ @errors_flush_interval = 1
78
+ @analytics_flush_interval = 10
79
+ @analytics_flush_at = 100
80
+ @analytics_max_queue = 5_000
81
+ @analytics_max_queue_bytes = 8 * 1024 * 1024
82
+ @shutdown_timeout = 2
83
+ end
84
+
85
+ # Fails loudly on a configuration that could never deliver anything, rather than letting the
86
+ # app run believing it reports errors.
87
+ def validate!
88
+ return unless api_key?
89
+
90
+ raise ArgumentError, "Fractal: collector_url is required when api_key is set" if blank?(collector_url)
91
+ raise ArgumentError, "Fractal: collector_url must be an http(s) URL, got #{collector_url.inspect}" unless collector_url.to_s.match?(%r{\Ahttps?://}i)
92
+
93
+ %i[user_resolver session_resolver player_id_for_user before_send_error].each do |name|
94
+ value = public_send(name)
95
+ raise ArgumentError, "Fractal: #{name} must respond to #call" if value && !value.respond_to?(:call)
96
+ end
97
+ raise ArgumentError, "Fractal: message_filters must be callables" unless Array(message_filters).all? { |f| f.respond_to?(:call) }
98
+ if blank?(anonymous_player_id) || blank?(system_player_id)
99
+ raise ArgumentError, "Fractal: anonymous_player_id and system_player_id must be non-empty"
100
+ end
101
+ return unless blank?(app_version)
102
+
103
+ logger.warn("[fractal] app_version is empty: error groups cannot be resolved or reopened by version")
104
+ end
105
+
106
+ def api_key? = !blank?(api_key)
107
+
108
+ # Checked on every call: a callable `enabled` is a runtime kill switch.
109
+ def enabled?
110
+ return false unless api_key?
111
+
112
+ enabled.respond_to?(:call) ? !!enabled.call : !!enabled
113
+ end
114
+
115
+ def environment
116
+ @environment ||= if defined?(::Rails) && ::Rails.respond_to?(:env)
117
+ ::Rails.env.to_s
118
+ else
119
+ ENV.fetch("RACK_ENV") { ENV.fetch("RAILS_ENV", "") }
120
+ end
121
+ end
122
+
123
+ def strict?
124
+ return @strict unless @strict.nil?
125
+
126
+ !%w[production staging].include?(environment)
127
+ end
128
+
129
+ def filter_parameters
130
+ return @filter_parameters if @filter_parameters
131
+ return ::Rails.application.config.filter_parameters if rails_app? && ::Rails.application.config.filter_parameters.any?
132
+
133
+ DEFAULT_FILTER_PARAMETERS
134
+ end
135
+
136
+ def project_root
137
+ @project_root ||= rails_app? ? ::Rails.root.to_s : Dir.pwd
138
+ end
139
+
140
+ def logger
141
+ @logger ||= if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
142
+ ::Rails.logger
143
+ else
144
+ require "logger"
145
+ Logger.new($stderr, progname: "fractal")
146
+ end
147
+ end
148
+
149
+ def endpoint(path) = "#{collector_url.to_s.chomp('/')}#{path}"
150
+
151
+ # Built on first use, not at configure time: it reads Rails' filter_parameters and ENV, which
152
+ # are only complete once the app has booted.
153
+ def scrubber
154
+ @scrubber ||= Scrubber.new(filter_parameters: filter_parameters, root: project_root,
155
+ extra_keys: extra_filter_keys, message_filters: message_filters)
156
+ end
157
+
158
+ private
159
+
160
+ def rails_app? = defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
161
+
162
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
163
+ end
164
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "json"
5
+
6
+ module Fractal
7
+ # Buffers scrubbed error events in memory and delivers them to POST /v1/errors from one
8
+ # background thread. Nothing here may ever slow down or break the request or job that raised:
9
+ # every public method rescues everything, and delivery is bounded in memory, time and rate.
10
+ #
11
+ # Deliberately not a job queue: those are usually database-backed, and the database is
12
+ # frequently the very thing that is broken when the report matters most.
13
+ #
14
+ # The buffer is a coalescing map, not a queue. Repeats of one error (same collector fingerprint,
15
+ # same player) increment occurrence_count instead of taking a slot, which is the storm control:
16
+ # a database outage that fails every request sends one event per distinct error per second.
17
+ class ErrorReporter
18
+ MAX_ENTRIES = 100 # distinct errors buffered; the collector caps a request at 100
19
+ MAX_POSTS_PER_FLUSH = 5 # collector limit is 10 req/s per key, burst 30
20
+
21
+ Entry = Struct.new(:event, :player_id, :fingerprint, :count, :first_at)
22
+
23
+ def initialize(config:, transport: Transport.new, breaker: Breaker.new, builder: nil,
24
+ flush_interval: config.errors_flush_interval, start_worker: true, clock: nil)
25
+ @config = config
26
+ @transport = transport
27
+ @breaker = breaker
28
+ @builder = builder
29
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
30
+ @flush_interval = flush_interval
31
+ @start_worker = start_worker
32
+ reset_state
33
+ end
34
+
35
+ def enabled?
36
+ @config.errors_enabled && @config.enabled? && !@breaker.revoked? && !@disabled
37
+ end
38
+
39
+ # Never raises. Called from Rails.error subscribers, i.e. from inside a failing request or job.
40
+ def report(exception, handled:, severity:, source:, context: {})
41
+ reset_after_fork
42
+ return unless enabled?
43
+ return if seen?(exception)
44
+
45
+ built = build { builder.build(exception, handled: handled, severity: severity, source: source, context: context) }
46
+ enqueue(built)
47
+ rescue Exception => e # rubocop:disable Lint/RescueException
48
+ log(:error, "report failed: #{e.class}")
49
+ end
50
+
51
+ # Never raises.
52
+ def report_message(message, severity:, frames:, context: {})
53
+ reset_after_fork
54
+ return unless enabled?
55
+
56
+ built = build { builder.build_message(message, severity: severity, frames: frames, context: context) }
57
+ enqueue(built)
58
+ rescue Exception => e # rubocop:disable Lint/RescueException
59
+ log(:error, "report failed: #{e.class}")
60
+ end
61
+
62
+ # Delivers up to MAX_POSTS_PER_FLUSH batches (one per player: /v1/errors has no per-event
63
+ # player). Never raises.
64
+ def flush(deadline: nil)
65
+ reset_after_fork
66
+ unless enabled?
67
+ discard_buffer
68
+ return
69
+ end
70
+ return if @breaker.open?
71
+
72
+ batches = take_batches
73
+ batches.each_with_index do |(player_id, entries), index|
74
+ if @breaker.open? || !enabled? || (deadline && now > deadline)
75
+ batches.drop(index).each { |_, rest| restore(rest) }
76
+ break
77
+ end
78
+ deliver(player_id, entries)
79
+ end
80
+ rescue Exception => e # rubocop:disable Lint/RescueException
81
+ log(:error, "flush failed: #{e.class}")
82
+ end
83
+
84
+ # Stops the worker and delivers what is left, within the deadline. Never raises.
85
+ def shutdown(timeout: @config.shutdown_timeout)
86
+ return unless @pid == Process.pid
87
+
88
+ @stopping = true
89
+ @mutex.synchronize { @wake.broadcast }
90
+ @thread&.join(0.5)
91
+ flush(deadline: now + timeout)
92
+ rescue Exception => e # rubocop:disable Lint/RescueException
93
+ log(:error, "shutdown failed: #{e.class}")
94
+ end
95
+
96
+ def stats
97
+ @mutex.synchronize { @stats.dup }
98
+ end
99
+
100
+ private
101
+
102
+ def reset_state
103
+ @pid = Process.pid
104
+ @mutex = Mutex.new
105
+ @wake = ConditionVariable.new
106
+ @buffer = {}
107
+ @stats = Hash.new(0)
108
+ @seen = ObjectSpace::WeakMap.new
109
+ @thread = nil
110
+ @stopping = false
111
+ @disabled = false
112
+ end
113
+
114
+ # A forked child (Puma worker, Solid Queue process) inherits the parent's buffer and a thread
115
+ # that no longer exists. Sending that buffer again would duplicate every event, so start clean.
116
+ def reset_after_fork
117
+ reset_state unless @pid == Process.pid
118
+ end
119
+
120
+ def builder
121
+ @builder ||= EventBuilder.new(config: @config, scrubber: @config.scrubber)
122
+ end
123
+
124
+ def now = @clock.call
125
+
126
+ # One exception object is reported at most once. Rails marks it with an ivar, but that cannot
127
+ # work on a frozen exception, and does not cover a wrapper whose cause was reported first.
128
+ def seen?(exception)
129
+ chain = [ exception ]
130
+ chain << chain.last.cause while chain.last.cause && chain.size < 10
131
+ @mutex.synchronize do
132
+ return true if chain.any? { |e| @seen.key?(e) }
133
+
134
+ chain.each { |e| @seen[e] = true }
135
+ end
136
+ false
137
+ end
138
+
139
+ # Fail closed: if the event cannot be built and scrubbed, it is dropped, never sent raw. The
140
+ # app's before_send_error hook gets the last look; nil drops the event.
141
+ def build
142
+ built = yield
143
+ hook = @config.before_send_error
144
+ return built unless hook
145
+
146
+ event = hook.call(built.event)
147
+ if event.nil?
148
+ bump(:dropped_by_hook)
149
+ return nil
150
+ end
151
+ built.with(event: event)
152
+ rescue Exception => e # rubocop:disable Lint/RescueException
153
+ bump(:dropped_scrub)
154
+ log(:error, "event dropped, could not be built: #{e.class}")
155
+ nil
156
+ end
157
+
158
+ def enqueue(built)
159
+ return unless built
160
+
161
+ add(built)
162
+ start_worker if @start_worker
163
+ end
164
+
165
+ def add(built)
166
+ key = [ built.player_id, built.fingerprint ]
167
+ @mutex.synchronize do
168
+ if (entry = @buffer[key])
169
+ entry.count += 1
170
+ elsif @buffer.size >= MAX_ENTRIES
171
+ @stats[:dropped_buffer_full] += 1
172
+ else
173
+ @buffer[key] = Entry.new(built.event, built.player_id, built.fingerprint, 1, Time.now.utc)
174
+ end
175
+ end
176
+ end
177
+
178
+ def take_batches
179
+ @mutex.synchronize do
180
+ batches = @buffer.values.group_by(&:player_id).first(MAX_POSTS_PER_FLUSH)
181
+ batches.each { |_, entries| entries.each { |entry| @buffer.delete(key_for(entry)) } }
182
+ batches
183
+ end
184
+ end
185
+
186
+ def key_for(entry) = [ entry.player_id, entry.fingerprint ]
187
+
188
+ # Puts undelivered entries back, merging with any repeats that arrived in the meantime.
189
+ def restore(entries)
190
+ @mutex.synchronize do
191
+ entries.each do |entry|
192
+ key = key_for(entry)
193
+ if (existing = @buffer[key])
194
+ existing.count += entry.count
195
+ existing.first_at = [ existing.first_at, entry.first_at ].min
196
+ elsif @buffer.size < MAX_ENTRIES
197
+ @buffer[key] = entry
198
+ else
199
+ @stats[:dropped_buffer_full] += entry.count
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ def deliver(player_id, entries)
206
+ body = serialise(player_id, entries)
207
+ return unless body
208
+
209
+ response = @transport.post(@config.endpoint("/v1/errors"), @config.api_key, body)
210
+ handle_response(response, entries)
211
+ rescue Exception => e # rubocop:disable Lint/RescueException
212
+ failed(entries, requeue: true)
213
+ log(:warn, "delivery failed: #{e.class}")
214
+ end
215
+
216
+ # Entries that cannot be serialised now never will be. They are dropped, not requeued: a
217
+ # requeue would fail on every flush and open the breaker analytics shares.
218
+ def serialise(player_id, entries)
219
+ payload(player_id, entries).to_json
220
+ rescue StandardError => e
221
+ bump(:dropped_invalid, entries.size)
222
+ log(:error, "batch of #{entries.size} error(s) dropped: cannot be serialised (#{e.class})")
223
+ nil
224
+ end
225
+
226
+ def handle_response(response, entries)
227
+ case response.code
228
+ when 200..299
229
+ succeeded(entries, response.body)
230
+ when 401
231
+ @breaker.revoke!
232
+ discard_buffer
233
+ log(:error, "collector answered 401: the API key is unknown or inactive. Fractal stops sending for the rest of this process")
234
+ when 402
235
+ @mutex.synchronize { @disabled = true }
236
+ discard_buffer
237
+ log(:warn, "collector answered 402: this plan has no error tracking. Error reporting is off for the rest of this process")
238
+ when 429, 500..599
239
+ failed(entries, requeue: true)
240
+ log(:warn, "collector answered #{response.code}; will retry")
241
+ else
242
+ failed(entries, requeue: false)
243
+ log(:error, "collector answered #{response.code}; batch of #{entries.size} error(s) dropped")
244
+ end
245
+ end
246
+
247
+ def succeeded(entries, body)
248
+ @breaker.success!
249
+ bump(:sent_events, entries.size)
250
+ rejected = Rejections.parse(body, entries.size)
251
+ return if rejected.empty?
252
+
253
+ bump(:rejected_events, rejected.size)
254
+ rejected.each do |r|
255
+ log(:error, "collector rejected error event #{r['index']} (#{entries[r['index']].event['error_type']}): #{r['reason']}")
256
+ end
257
+ end
258
+
259
+ def failed(entries, requeue:)
260
+ @breaker.failure!
261
+ bump(:failed_posts)
262
+ restore(entries) if requeue
263
+ end
264
+
265
+ def discard_buffer
266
+ @mutex.synchronize { @buffer.clear }
267
+ end
268
+
269
+ def payload(player_id, entries)
270
+ os = Etc.uname
271
+ {
272
+ sent_at: Time.now.utc.iso8601(3),
273
+ context: {
274
+ player_id: player_id, session_token: "", platform: "server", app_version: @config.app_version.to_s,
275
+ environment: @config.environment, runtime_version: "ruby #{RUBY_VERSION}", os_name: os[:sysname],
276
+ os_version: os[:release], sdk_version: Fractal.sdk_version
277
+ },
278
+ errors: entries.map do |entry|
279
+ entry.event.merge("occurrence_count" => entry.count, "timestamp" => entry.first_at.iso8601(3))
280
+ end
281
+ }
282
+ end
283
+
284
+ def start_worker
285
+ return if @thread&.alive?
286
+
287
+ @mutex.synchronize do
288
+ return if @thread&.alive?
289
+
290
+ @thread = Thread.new { work }
291
+ @thread.report_on_exception = false
292
+ end
293
+ end
294
+
295
+ def work
296
+ until @stopping
297
+ @mutex.synchronize { @wake.wait(@mutex, @flush_interval) }
298
+ flush unless @stopping
299
+ end
300
+ rescue Exception => e # rubocop:disable Lint/RescueException
301
+ log(:error, "worker died: #{e.class}")
302
+ end
303
+
304
+ def bump(stat, by = 1)
305
+ @mutex.synchronize { @stats[stat] += by }
306
+ end
307
+
308
+ # Class names only: an exception message may carry exactly what the scrubber exists to remove.
309
+ def log(level, message)
310
+ @config.logger&.public_send(level, "[fractal] #{message}")
311
+ end
312
+ end
313
+
314
+ # Reads rejected[] out of a 202 body, defensively (ANALYTICS_PROTOCOL.md, CRASH_PROTOCOL.md): an
315
+ # entry whose index is missing, not a whole number, out of range or a duplicate is ignored, and
316
+ # a malformed or empty body rejects nothing.
317
+ module Rejections
318
+ module_function
319
+
320
+ def parse(body, size)
321
+ json = JSON.parse(body.to_s)
322
+ list = json.is_a?(Hash) ? json["rejected"] : nil
323
+ return [] unless list.is_a?(Array)
324
+
325
+ seen = {}
326
+ list.select do |entry|
327
+ next false unless entry.is_a?(Hash)
328
+
329
+ index = entry["index"]
330
+ next false unless index.is_a?(Integer) && index >= 0 && index < size && !seen[index]
331
+
332
+ seen[index] = true
333
+ end
334
+ rescue JSON::ParserError
335
+ []
336
+ end
337
+ end
338
+ end