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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/VERSION +1 -0
- data/lib/fractal/analytics.rb +437 -0
- data/lib/fractal/breaker.rb +69 -0
- data/lib/fractal/client.rb +106 -0
- data/lib/fractal/configuration.rb +164 -0
- data/lib/fractal/error_reporter.rb +338 -0
- data/lib/fractal/event_builder.rb +95 -0
- data/lib/fractal/fingerprint.rb +287 -0
- data/lib/fractal/identity.rb +52 -0
- data/lib/fractal/rails/error_subscriber.rb +14 -0
- data/lib/fractal/rails/middleware.rb +41 -0
- data/lib/fractal/rails/railtie.rb +36 -0
- data/lib/fractal/rails/warden_hooks.rb +39 -0
- data/lib/fractal/scope.rb +95 -0
- data/lib/fractal/scrubber.rb +253 -0
- data/lib/fractal/transport.rb +25 -0
- data/lib/fractal/version.rb +6 -0
- data/lib/fractal-ruby.rb +4 -0
- data/lib/fractal.rb +111 -0
- metadata +63 -0
|
@@ -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
|