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,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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Kept in step with sdks/ruby/VERSION by ci/check_version_sync.sh.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires a gem by its name; the library itself is `require "fractal"`.
4
+ require_relative "fractal"
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: []