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,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractal
|
|
4
|
+
# Removes secrets and PII from everything the error reporter sends. Pure: no I/O, no global
|
|
5
|
+
# state beyond what the constructor reads, so it can be specced exhaustively. It is a second
|
|
6
|
+
# layer: EventBuilder builds payloads from an allowlist of fields and never passes params,
|
|
7
|
+
# headers, cookies, session, ENV or job arguments here in the first place.
|
|
8
|
+
#
|
|
9
|
+
# Anything that raises propagates. The reporter treats that as "drop the event", never "send it
|
|
10
|
+
# unscrubbed".
|
|
11
|
+
class Scrubber
|
|
12
|
+
FILTERED = "[FILTERED]"
|
|
13
|
+
|
|
14
|
+
# Keys Rails' own filter_parameters does not cover by default.
|
|
15
|
+
EXTRA_KEYS = %w[authorization cookie session csrf credit_card stripe].freeze
|
|
16
|
+
|
|
17
|
+
# Exception classes whose message quotes the input that failed to parse, e.g. a request body.
|
|
18
|
+
# Matched by name so a class that is not loaded in this process is simply never matched.
|
|
19
|
+
INPUT_ECHOING = %w[
|
|
20
|
+
JSON::ParserError Psych::SyntaxError CSV::MalformedCSVError URI::InvalidURIError
|
|
21
|
+
ActionDispatch::Http::Parameters::ParseError Rack::QueryParser::ParameterTypeError
|
|
22
|
+
Rack::QueryParser::InvalidParameterError
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
MAX_CAUSES = 10
|
|
26
|
+
MAX_MESSAGE_CHARS = 1_000
|
|
27
|
+
MAX_FRAMES = 100
|
|
28
|
+
MAX_DEPTH = 8
|
|
29
|
+
MIN_ENV_VALUE_LENGTH = 8
|
|
30
|
+
|
|
31
|
+
# Order matters: URL userinfo and credentials go before the email pattern, which would
|
|
32
|
+
# otherwise eat the tail of `user:pass@host.com`.
|
|
33
|
+
VALUE_PATTERNS = [
|
|
34
|
+
[ %r{(\b[a-z][a-z0-9+.\-]*://)[^\s/:@]+:[^\s/@]+@}i, "\\1#{FILTERED}@" ],
|
|
35
|
+
[ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+\/=\-]{8,}/, FILTERED ],
|
|
36
|
+
[ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/, FILTERED ],
|
|
37
|
+
[ /\b(?:sk|rk|pk|whsec)_[A-Za-z0-9_]{8,}/, FILTERED ],
|
|
38
|
+
[ /[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)*\.[A-Za-z]{2,}/, FILTERED ],
|
|
39
|
+
[ /\b\h{32,}\b/, FILTERED ]
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
GEM_FRAME = %r{\A.*/gems/([^/]+)/(.*)\z}
|
|
43
|
+
STDLIB_FRAME = %r{\A.*/lib/ruby/\d+\.\d+\.\d+/(.*)\z}
|
|
44
|
+
ABSOLUTE_DIRS = %r{\A/(?:[^/:]*/)*}
|
|
45
|
+
# Ruby 3.3+ eval frames: "(eval at /abs/x.rb:3):1:in 'm'". The wrapper carries the host path.
|
|
46
|
+
EVAL_FRAME = /\A\(eval at (.+):\d+\):(\d+:in .*)\z/m
|
|
47
|
+
|
|
48
|
+
# The gem's own source: lib/fractal.rb, lib/fractal-ruby.rb and lib/fractal/**.
|
|
49
|
+
OWN_PREFIX = "#{File.expand_path('..', __dir__)}/fractal".freeze
|
|
50
|
+
|
|
51
|
+
def initialize(filter_parameters:, root:, env: ENV, extra_keys: [], message_filters: [], own_prefix: OWN_PREFIX)
|
|
52
|
+
@key_filter = KeyFilter.build(Array(filter_parameters) + EXTRA_KEYS + Array(extra_keys).map(&:to_s))
|
|
53
|
+
@env_values = secret_env_regexp(env)
|
|
54
|
+
@root_prefix = "#{root.to_s.chomp('/')}/"
|
|
55
|
+
@own_prefix = own_prefix
|
|
56
|
+
@message_filters = Array(message_filters)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Deep-scrubs hashes, arrays and strings. Keys are stringified. Anything that is not a plain
|
|
60
|
+
# scalar is reduced to its class name, never inspected.
|
|
61
|
+
def scrub(value)
|
|
62
|
+
walk(@key_filter.filter(value), 0)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def message(exception)
|
|
66
|
+
chain = cause_chain(exception)
|
|
67
|
+
raw = custom_message(exception) || builtin_message(exception, chain)
|
|
68
|
+
return "#{exception.class.name} (message withheld)" if chain.any? { |e| input_echoing?(e) }
|
|
69
|
+
|
|
70
|
+
text = scrub_string(raw.to_s)[0, MAX_MESSAGE_CHARS]
|
|
71
|
+
text.empty? ? exception.class.name.to_s : text
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# For capture_message: the caller's own text, scrubbed and capped like an exception message.
|
|
75
|
+
def text(raw)
|
|
76
|
+
scrub_string(raw.to_s)[0, MAX_MESSAGE_CHARS]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Frames only, with no message header: the collector's fingerprint falls back to the first
|
|
80
|
+
# line, which must be a frame, never the (variable) message. The gem's own frames are dropped:
|
|
81
|
+
# its middleware is on every trace and would otherwise be picked as the first frame for errors
|
|
82
|
+
# raised outside any app code.
|
|
83
|
+
def backtrace(exception)
|
|
84
|
+
frames(exception.backtrace || [])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def frames(lines)
|
|
88
|
+
kept = lines.reject { |frame| frame.start_with?(@own_prefix) }.first(MAX_FRAMES)
|
|
89
|
+
kept.map { |frame| scrub_string(rewrite_frame(frame)) }.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def walk(value, depth)
|
|
95
|
+
return "[TRUNCATED]" if depth > MAX_DEPTH
|
|
96
|
+
|
|
97
|
+
case value
|
|
98
|
+
when Hash then value.to_h { |k, v| [ k.to_s, walk(v, depth + 1) ] }
|
|
99
|
+
when Array then value.map { |v| walk(v, depth + 1) }
|
|
100
|
+
when String then scrub_string(value)
|
|
101
|
+
when Symbol then scrub_string(value.to_s)
|
|
102
|
+
when Integer, Float, true, false, nil then value
|
|
103
|
+
when Time then value.iso8601
|
|
104
|
+
else
|
|
105
|
+
return value.iso8601 if value.respond_to?(:iso8601) && value.class.name.to_s.match?(/\ADate(Time)?\z/)
|
|
106
|
+
|
|
107
|
+
value.class.name || "Object"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def scrub_string(string)
|
|
112
|
+
text = string.encoding == Encoding::BINARY ? string.dup.force_encoding(Encoding::UTF_8) : string
|
|
113
|
+
text = text.scrub("?")
|
|
114
|
+
text = text.gsub(@env_values, FILTERED) if @env_values
|
|
115
|
+
VALUE_PATTERNS.reduce(text) { |acc, (pattern, replacement)| acc.gsub(pattern, replacement) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The exception and its causes, outermost first. Wrappers such as ActionView::Template::Error
|
|
119
|
+
# copy their cause's message, so the checks on what a message may carry look at the whole chain.
|
|
120
|
+
def cause_chain(exception)
|
|
121
|
+
chain = [ exception ]
|
|
122
|
+
chain << chain.last.cause while chain.last.cause && chain.size < MAX_CAUSES
|
|
123
|
+
chain
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The app's own rules (config.message_filters) go first: they know their database adapters.
|
|
127
|
+
def custom_message(exception)
|
|
128
|
+
raw = exception.message.to_s
|
|
129
|
+
@message_filters.each do |filter|
|
|
130
|
+
replacement = filter.call(exception, raw)
|
|
131
|
+
return replacement.to_s unless replacement.nil?
|
|
132
|
+
end
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Invariant: a database error contributes no free text, only codes and names. The fingerprint
|
|
137
|
+
# hashes the message, so a quoted value would also split one bug into a group per value.
|
|
138
|
+
def builtin_message(exception, chain)
|
|
139
|
+
raw = exception.message.to_s
|
|
140
|
+
if (pg_error = chain.find { |e| postgres_error?(e) })
|
|
141
|
+
postgres_message(pg_error)
|
|
142
|
+
elsif chain.any? { |e| active_record_error?(e) }
|
|
143
|
+
raw.lines.first.to_s.chomp
|
|
144
|
+
else
|
|
145
|
+
raw
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def postgres_error?(error) = defined?(::PG::Error) && error.is_a?(::PG::Error)
|
|
150
|
+
|
|
151
|
+
def active_record_error?(error) = defined?(::ActiveRecord::ActiveRecordError) && error.is_a?(::ActiveRecord::ActiveRecordError)
|
|
152
|
+
|
|
153
|
+
# Postgres: structured fields only, "<PG class> <SQLSTATE> <constraint>", read from the
|
|
154
|
+
# PG::Result and never parsed out of the message text. The result is nil for connection
|
|
155
|
+
# failures and may already be cleared; either way the class name alone is sent.
|
|
156
|
+
def postgres_message(error)
|
|
157
|
+
result = error.result
|
|
158
|
+
fields = result ? [ ::PG::Result::PG_DIAG_SQLSTATE, ::PG::Result::PG_DIAG_CONSTRAINT_NAME ].map { |f| result.error_field(f) } : []
|
|
159
|
+
[ error.class.name, *fields ].reject { |f| f.nil? || f.to_s.empty? }.join(" ")
|
|
160
|
+
rescue ::PG::Error
|
|
161
|
+
error.class.name.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def input_echoing?(exception)
|
|
165
|
+
exception.class.ancestors.any? { |klass| INPUT_ECHOING.include?(klass.name) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Gem and stdlib frames first: a gem vendored under the app root must not read as an app frame.
|
|
169
|
+
# An eval frame is rewritten as its origin file, so it reads as an ordinary Ruby frame: no host
|
|
170
|
+
# path, and no " at " for the collector's JS detector to claim.
|
|
171
|
+
def rewrite_frame(frame)
|
|
172
|
+
if (match = EVAL_FRAME.match(frame))
|
|
173
|
+
rewrite_frame("#{match[1]}:#{match[2]}")
|
|
174
|
+
elsif (match = GEM_FRAME.match(frame))
|
|
175
|
+
"[gem] #{match[1]}/#{match[2]}"
|
|
176
|
+
elsif (match = STDLIB_FRAME.match(frame))
|
|
177
|
+
"[ruby] #{match[1]}"
|
|
178
|
+
elsif frame.start_with?(@root_prefix)
|
|
179
|
+
frame.delete_prefix(@root_prefix)
|
|
180
|
+
elsif frame.start_with?("/")
|
|
181
|
+
"[other] #{frame.sub(ABSOLUTE_DIRS, '')}"
|
|
182
|
+
else
|
|
183
|
+
frame
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Exact values of denylisted variables. The length floor stops a flag like SECRET_FLAG=1 from
|
|
188
|
+
# replacing every "1" in every message.
|
|
189
|
+
def secret_env_regexp(env)
|
|
190
|
+
values = env.filter_map do |name, value|
|
|
191
|
+
next unless value.to_s.length >= MIN_ENV_VALUE_LENGTH
|
|
192
|
+
next unless @key_filter.filtered_key?(name)
|
|
193
|
+
|
|
194
|
+
value.to_s
|
|
195
|
+
end
|
|
196
|
+
values.empty? ? nil : Regexp.union(values.sort_by { |v| -v.length })
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Masks values whose key matches the filter list. Uses ActiveSupport::ParameterFilter when it is
|
|
201
|
+
# loaded, so a Rails app's filter_parameters (strings, regexps, procs, dotted keys) mean exactly
|
|
202
|
+
# what they mean to Rails' own log filtering. Without ActiveSupport, strings and symbols match as
|
|
203
|
+
# case-insensitive substrings and regexps as given.
|
|
204
|
+
class KeyFilter
|
|
205
|
+
def self.build(list)
|
|
206
|
+
if parameter_filter_available?
|
|
207
|
+
new(::ActiveSupport::ParameterFilter.new(list, mask: Scrubber::FILTERED))
|
|
208
|
+
else
|
|
209
|
+
new(PlainFilter.new(list))
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.parameter_filter_available?
|
|
214
|
+
return true if defined?(::ActiveSupport::ParameterFilter)
|
|
215
|
+
|
|
216
|
+
require "active_support/parameter_filter"
|
|
217
|
+
true
|
|
218
|
+
rescue LoadError
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def initialize(filter)
|
|
223
|
+
@filter = filter
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def filter(value) = @filter.filter(payload: value)[:payload]
|
|
227
|
+
|
|
228
|
+
def filtered_key?(name) = @filter.filter(name.to_s => "x")[name.to_s] == Scrubber::FILTERED
|
|
229
|
+
|
|
230
|
+
class PlainFilter
|
|
231
|
+
def initialize(list)
|
|
232
|
+
@patterns = list.filter_map do |item|
|
|
233
|
+
case item
|
|
234
|
+
when Regexp then item
|
|
235
|
+
when String, Symbol then Regexp.new(Regexp.escape(item.to_s), Regexp::IGNORECASE)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def filter(hash) = walk(hash)
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def walk(value)
|
|
245
|
+
case value
|
|
246
|
+
when Hash then value.to_h { |k, v| [ k, @patterns.any? { |p| p.match?(k.to_s) } ? Scrubber::FILTERED : walk(v) ] }
|
|
247
|
+
when Array then value.map { |v| walk(v) }
|
|
248
|
+
else value
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Fractal
|
|
7
|
+
# One POST to the collector. Raises on any network failure; the caller counts that against the
|
|
8
|
+
# circuit breaker.
|
|
9
|
+
class Transport
|
|
10
|
+
Response = Data.define(:code, :body)
|
|
11
|
+
|
|
12
|
+
TIMEOUT = 2
|
|
13
|
+
|
|
14
|
+
def post(endpoint, api_key, json)
|
|
15
|
+
uri = URI.parse(endpoint)
|
|
16
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
17
|
+
http.use_ssl = uri.scheme == "https"
|
|
18
|
+
http.open_timeout = http.read_timeout = http.write_timeout = self.class::TIMEOUT
|
|
19
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "X-API-Key" => api_key)
|
|
20
|
+
request.body = json
|
|
21
|
+
response = http.request(request)
|
|
22
|
+
Response.new(code: response.code.to_i, body: response.body.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/fractal-ruby.rb
ADDED
data/lib/fractal.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "fractal/version"
|
|
7
|
+
require_relative "fractal/scope"
|
|
8
|
+
require_relative "fractal/configuration"
|
|
9
|
+
require_relative "fractal/identity"
|
|
10
|
+
require_relative "fractal/fingerprint"
|
|
11
|
+
require_relative "fractal/scrubber"
|
|
12
|
+
require_relative "fractal/event_builder"
|
|
13
|
+
require_relative "fractal/transport"
|
|
14
|
+
require_relative "fractal/breaker"
|
|
15
|
+
require_relative "fractal/error_reporter"
|
|
16
|
+
require_relative "fractal/analytics"
|
|
17
|
+
require_relative "fractal/client"
|
|
18
|
+
|
|
19
|
+
# Error tracking and server-side analytics for Ruby backends, reporting to Fractal. The facade
|
|
20
|
+
# matches the JavaScript, Godot and LÖVE SDKs: Fractal.configure, Fractal.errors.capture_error,
|
|
21
|
+
# Fractal.analytics.track, Fractal.flush and Fractal.shutdown. A subsystem that is off is a set of
|
|
22
|
+
# no-ops, never nil.
|
|
23
|
+
#
|
|
24
|
+
# Fractal.configure do |c|
|
|
25
|
+
# c.api_key = ENV["FRACTAL_API_KEY"]
|
|
26
|
+
# c.app_version = ENV["GIT_SHA"]
|
|
27
|
+
# end
|
|
28
|
+
# Fractal.analytics.track("checkout_completed", { "total_cents" => 1299 })
|
|
29
|
+
module Fractal
|
|
30
|
+
class << self
|
|
31
|
+
def configure
|
|
32
|
+
config = Configuration.new
|
|
33
|
+
yield config if block_given?
|
|
34
|
+
config.validate!
|
|
35
|
+
install(Client.new(config))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Swaps the active client, shutting the old one down so its queued events are delivered.
|
|
39
|
+
def install(client)
|
|
40
|
+
previous = @client
|
|
41
|
+
@client = client
|
|
42
|
+
previous&.shutdown
|
|
43
|
+
client.register_at_exit
|
|
44
|
+
client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def client = @client || NULL_CLIENT
|
|
48
|
+
|
|
49
|
+
def configured? = !@client.nil?
|
|
50
|
+
|
|
51
|
+
def errors = client.errors
|
|
52
|
+
|
|
53
|
+
def analytics = client.analytics
|
|
54
|
+
|
|
55
|
+
def flush = client.flush
|
|
56
|
+
|
|
57
|
+
def shutdown = client.shutdown
|
|
58
|
+
|
|
59
|
+
# Request-scoped context, shared by errors and analytics events. Never global: each request
|
|
60
|
+
# and job has its own (see Fractal::Scope).
|
|
61
|
+
|
|
62
|
+
def set_user(player_id)
|
|
63
|
+
Scope.current.user = player_id.nil? || player_id.to_s.empty? ? nil : player_id.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_tag(key, value)
|
|
67
|
+
Scope.current.set_tag(key, value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_breadcrumb(message, category: "default", level: "info", data: nil)
|
|
71
|
+
crumb = { "timestamp" => Time.now.utc.iso8601(3), "category" => category.to_s, "message" => message.to_s, "level" => level.to_s }
|
|
72
|
+
crumb["data"] = data if data
|
|
73
|
+
Scope.current.add_breadcrumb(crumb)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The session for the rest of this request, e.g. a game client's own session token passed
|
|
77
|
+
# through to the server, in place of the default (the hashed web session id).
|
|
78
|
+
def set_session(token)
|
|
79
|
+
Scope.current.session_token = token.nil? || token.to_s.empty? ? nil : token.to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Links what happened before sign-in to the user: tracks `identify` in the current (pre-login)
|
|
83
|
+
# session under player_id, then attributes the rest of the request to player_id. The Railtie
|
|
84
|
+
# calls this from Warden's sign-in hook when identify_on_login is on.
|
|
85
|
+
def identify(player_id)
|
|
86
|
+
player_id = player_id.to_s
|
|
87
|
+
return if player_id.empty?
|
|
88
|
+
|
|
89
|
+
scope = Scope.current
|
|
90
|
+
# The scope's user or the bucket, never the user_resolver: by the time Warden's sign-in hook
|
|
91
|
+
# runs, a resolver reading current_user already names the user who just signed in.
|
|
92
|
+
previous = scope.user.to_s
|
|
93
|
+
previous = Identity.new(client.config).bucket(scope: scope, controller: ExecutionContext.current[:controller]) if previous.empty?
|
|
94
|
+
analytics.identify(player_id, previous_player_id: previous) unless previous == player_id
|
|
95
|
+
scope.user = player_id
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Runs the block in a fresh scope (a unit of work outside Rails' request and job hooks).
|
|
99
|
+
def with_scope(env = nil, &) = Scope.isolated(env, &)
|
|
100
|
+
|
|
101
|
+
def sdk_version = "fractal-ruby/#{VERSION}"
|
|
102
|
+
|
|
103
|
+
# For tests: forget the active client without delivering anything.
|
|
104
|
+
def reset!
|
|
105
|
+
@client = nil
|
|
106
|
+
Scope.clear
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
require_relative "fractal/rails/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fractal-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Fractal
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-10-12 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Reports a Rails app's exceptions to Fractal's /v1/errors and sends server-side
|
|
13
|
+
analytics events to /v1/batch, from a background thread that never slows a request.
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- CHANGELOG.md
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- VERSION
|
|
22
|
+
- lib/fractal-ruby.rb
|
|
23
|
+
- lib/fractal.rb
|
|
24
|
+
- lib/fractal/analytics.rb
|
|
25
|
+
- lib/fractal/breaker.rb
|
|
26
|
+
- lib/fractal/client.rb
|
|
27
|
+
- lib/fractal/configuration.rb
|
|
28
|
+
- lib/fractal/error_reporter.rb
|
|
29
|
+
- lib/fractal/event_builder.rb
|
|
30
|
+
- lib/fractal/fingerprint.rb
|
|
31
|
+
- lib/fractal/identity.rb
|
|
32
|
+
- lib/fractal/rails/error_subscriber.rb
|
|
33
|
+
- lib/fractal/rails/middleware.rb
|
|
34
|
+
- lib/fractal/rails/railtie.rb
|
|
35
|
+
- lib/fractal/rails/warden_hooks.rb
|
|
36
|
+
- lib/fractal/scope.rb
|
|
37
|
+
- lib/fractal/scrubber.rb
|
|
38
|
+
- lib/fractal/transport.rb
|
|
39
|
+
- lib/fractal/version.rb
|
|
40
|
+
homepage: https://getfractal.dev
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
documentation_uri: https://getfractal.dev/help/sdk-integration/ruby
|
|
45
|
+
rubygems_mfa_required: 'true'
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.2'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.6.2
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: Error tracking and analytics for Ruby on Rails backends, reporting to Fractal.
|
|
63
|
+
test_files: []
|