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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Turns an exception plus Rails' error context into one /v1/errors event. Allowlist, not
5
+ # blocklist: the event is assembled from named safe fields, and never from params, headers,
6
+ # cookies, session, ENV or job arguments. The Scrubber is a second layer over what is left.
7
+ #
8
+ # Reads only what is already in memory (the controller and job instances in the execution
9
+ # context, and the scope). It must never touch the database: the database is frequently the
10
+ # very thing that is down when an error needs reporting.
11
+ class EventBuilder
12
+ Built = Data.define(:player_id, :fingerprint, :event)
13
+
14
+ def initialize(config:, scrubber:, identity: Identity.new(config))
15
+ @config = config
16
+ @scrubber = scrubber
17
+ @identity = identity
18
+ end
19
+
20
+ def build(exception, handled:, severity:, source:, context:, scope: Scope.current)
21
+ error_type = exception.class.name || "AnonymousError"
22
+ event(error_type, @scrubber.message(exception), @scrubber.backtrace(exception),
23
+ handled: handled, severity: severity, source: source, context: context, scope: scope)
24
+ end
25
+
26
+ # capture_message: no exception, so the stack is where the message was captured from.
27
+ def build_message(message, severity:, frames:, context:, scope: Scope.current)
28
+ text = @scrubber.text(message)
29
+ text = "(empty message)" if text.empty?
30
+ event("Message", text, @scrubber.frames(frames),
31
+ handled: true, severity: severity, source: "fractal.capture_message", context: context, scope: scope)
32
+ end
33
+
34
+ private
35
+
36
+ def event(error_type, message, stack_trace, handled:, severity:, source:, context:, scope:)
37
+ controller = context[:controller]
38
+ job = context[:job]
39
+ player_id = utf8(@identity.player_id(scope: scope, controller: controller))
40
+
41
+ event = {
42
+ "error_type" => error_type,
43
+ "message" => message,
44
+ "stack_trace" => stack_trace,
45
+ "severity" => severity.to_s,
46
+ "handled" => handled,
47
+ "tags" => @scrubber.scrub(tags(source, controller, job).merge(scope.tags)),
48
+ "extra" => @scrubber.scrub(extra(controller, job)),
49
+ "breadcrumbs" => @scrubber.scrub(scope.breadcrumbs),
50
+ "user" => { "id" => player_id },
51
+ "runtime" => { "name" => "ruby", "version" => RUBY_VERSION },
52
+ "release" => @config.app_version.to_s,
53
+ "environment" => @config.environment
54
+ }
55
+ event.delete("breadcrumbs") if scope.breadcrumbs.empty?
56
+ Built.new(
57
+ player_id: player_id,
58
+ fingerprint: Fingerprint.generate_fingerprint(error_type, message, stack_trace),
59
+ event: event
60
+ )
61
+ end
62
+
63
+ # The id is the one string here the Scrubber does not pass over. Invalid bytes are replaced,
64
+ # not refused: the error is worth more than the exact id.
65
+ def utf8(id)
66
+ text = id.to_s
67
+ text = text.dup.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
68
+ text.scrub("?")
69
+ end
70
+
71
+ # environment rides in tags until the collector stores it (#485).
72
+ def tags(source, controller, job)
73
+ tags = { "environment" => @config.environment, "source" => source.to_s }
74
+ tags["controller_action"] = "#{controller.controller_path}##{controller.action_name}" if controller.respond_to?(:controller_path)
75
+ tags["job"] = job.class.name if job
76
+ tags
77
+ end
78
+
79
+ # Fixed scalar keys only.
80
+ def extra(controller, job)
81
+ extra = {}
82
+ if controller.respond_to?(:request) && (request = controller.request)
83
+ extra["request_id"] = request.request_id if request.respond_to?(:request_id)
84
+ extra["method"] = request.request_method if request.respond_to?(:request_method)
85
+ extra["route"] = request.route_uri_pattern if request.respond_to?(:route_uri_pattern)
86
+ end
87
+ if job
88
+ extra["job_id"] = job.job_id if job.respond_to?(:job_id)
89
+ extra["queue"] = job.queue_name if job.respond_to?(:queue_name)
90
+ extra["executions"] = job.executions if job.respond_to?(:executions)
91
+ end
92
+ extra.compact
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Fractal
6
+ # The collector's error-fingerprint grouping (collector/internal/ingest/fingerprint.go,
7
+ # frame_extract.go), ported so the reporter can coalesce repeats of one error into one event
8
+ # with an occurrence_count before sending. The key only has to agree with the collector's for
9
+ # coalescing to match its grouping, so it is pinned to Go's own output by the shared vectors in
10
+ # collector/internal/ingest/fingerprint_vectors.json (spec/fractal/fingerprint_spec.rb).
11
+ #
12
+ # Fractal's app keeps its own copy (app/services/concerns/error_fingerprint.rb) for the
13
+ # backfill services, which also need the pre-normalization v1 hash.
14
+ module Fingerprint
15
+ # Multi-run constructs: each requires literal delimiter characters (@, <,
16
+ # #, >, -, T, :, Z) that plain alphanumeric identifiers can't contain, so
17
+ # matching them directly (no lookaround needed) can't false-positive on a
18
+ # bare identifier like "Node2D".
19
+ GODOT_RESOURCE_PATTERN = /<[A-Za-z_][A-Za-z0-9_]*#-?\d+>/
20
+ GODOT_NODE_PATTERN = /@[A-Za-z_][A-Za-z0-9_]*@\d+/
21
+ ISO_DATE_PATTERN = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?/
22
+ UUID_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/
23
+
24
+ # Single-run token classes: classified against the *whole* token, so a
25
+ # mixed token like "Node2D" never partially matches.
26
+ WORD_RUN_PATTERN = /[A-Za-z0-9_]+/
27
+ HEX_PREFIX_PATTERN = /\A0[xX][0-9a-fA-F]+\z/
28
+ BARE_HEX_PATTERN = /\A[0-9a-fA-F]+\z/
29
+ HEX_LETTER_PATTERN = /[a-fA-F]/
30
+ INT_PATTERN = /\A[0-9]+\z/
31
+
32
+ # Mirrors Sentry's MAX_INPUT_LENGTH: above this, skip parameterization
33
+ # entirely rather than pay for regex passes over an arbitrarily large
34
+ # message.
35
+ MAX_NORMALIZE_INPUT_LEN = 8192
36
+
37
+ # \z, not $ — Ruby's $ (unlike Go's) matches before a trailing "\n" at the
38
+ # end of the string, which would silently mis-strip a frame line whose
39
+ # source had a trailing newline. See fingerprint_vectors.json's
40
+ # "js_frame_with_trailing_newline" vector.
41
+ LINE_COL_PATTERN = /:\d+(?::\d+)?(\)?)\z/
42
+ GOROUTINE_HEADER_PATTERN = /\Agoroutine \d+ \[[^\]]+\]:\z/
43
+ GO_FILE_LINE_PATTERN = /\A(.+\.go):\d+(\s+\+0x[0-9a-fA-F]+)?\z/
44
+ LUA_MID_LINE_COL_PATTERN = /:\d+:/
45
+
46
+ # Ruby frames read "path.rb:NN:in 'Class#method'" (backtick-quoted before Ruby 3.4). The optional
47
+ # [gem]/[ruby]/[other] prefix is how Fractal::Scrubber rewrites non-app paths, so an in-app
48
+ # frame is always a bare app/ or lib/ path.
49
+ #
50
+ # ERB frames read "app/views/x.html.erb:12:in '_app_views_x_html_erb___123'": the label is a
51
+ # compiled-method id that changes per process, so the whole ":NN:in ..." tail is dropped.
52
+ RUBY_FRAME_PATTERN = /\A(?:\[(?:gem|ruby|other)\] )?\S+\.(?:rb|erb):\d+:in /
53
+ RUBY_LINE_PATTERN = /\A((?:\[(?:gem|ruby|other)\] )?\S+\.rb):\d+(:in )/
54
+ RUBY_ERB_LINE_PATTERN = /\A((?:\[(?:gem|ruby|other)\] )?\S+\.erb):\d+:in .*\z/
55
+
56
+ module_function
57
+
58
+ # generate_fingerprint is the SHA256 fingerprint used to group errors,
59
+ # normalizing both the message and the in-app stack frame first.
60
+ def generate_fingerprint(error_type, message, stack_trace)
61
+ raw = "#{error_type}\x00#{normalize_message(message)}\x00#{normalize_top_frame(stack_trace)}"
62
+ Digest::SHA256.hexdigest(raw)
63
+ end
64
+
65
+ def normalize_message(message)
66
+ return message if message.bytesize > MAX_NORMALIZE_INPUT_LEN
67
+
68
+ s = message.gsub(GODOT_RESOURCE_PATTERN, "<resource>")
69
+ s = s.gsub(GODOT_NODE_PATTERN, "<node>")
70
+ s = s.gsub(ISO_DATE_PATTERN, "<date>")
71
+ s = s.gsub(UUID_PATTERN, "<uuid>")
72
+
73
+ tokenize_and_normalize(s)
74
+ end
75
+
76
+ # Walks s once, classifying each whole run of [A-Za-z0-9_] characters.
77
+ # Separator runs (including the "." that joins two digit runs into a
78
+ # float) pass through untouched.
79
+ def tokenize_and_normalize(s)
80
+ matches = []
81
+ s.scan(WORD_RUN_PATTERN) { matches << Regexp.last_match }
82
+ return s if matches.empty?
83
+
84
+ out = +""
85
+ pos = 0
86
+ i = 0
87
+ while i < matches.length
88
+ m = matches[i]
89
+ start_idx = m.begin(0)
90
+ end_idx = m.end(0)
91
+ out << s[pos...start_idx]
92
+ token = m[0]
93
+
94
+ if i + 1 < matches.length && INT_PATTERN.match?(token)
95
+ next_m = matches[i + 1]
96
+ if s[end_idx...next_m.begin(0)] == "." && INT_PATTERN.match?(next_m[0])
97
+ out << "<float>"
98
+ pos = next_m.end(0)
99
+ i += 2
100
+ next
101
+ end
102
+ end
103
+
104
+ out << if HEX_PREFIX_PATTERN.match?(token)
105
+ "<hex>"
106
+ elsif token.length >= 8 && BARE_HEX_PATTERN.match?(token) && HEX_LETTER_PATTERN.match?(token)
107
+ # Bare hex must contain a letter — a pure digit run of any length is
108
+ # <int>, not <hex>.
109
+ "<hex>"
110
+ elsif INT_PATTERN.match?(token)
111
+ "<int>"
112
+ else
113
+ token
114
+ end
115
+ pos = end_idx
116
+ i += 1
117
+ end
118
+ out << s[pos..]
119
+ out
120
+ end
121
+
122
+ # Extracts and normalizes the in-app frame of a stack trace. Tries each
123
+ # engine-specific extractor in turn; an unrecognized or future format
124
+ # falls through to normalize_top_frame_legacy unchanged. See
125
+ # sdks/shared/CRASH_PROTOCOL.md for the frame-format contract.
126
+ def normalize_top_frame(stack_trace)
127
+ return "" if stack_trace.nil? || stack_trace.empty?
128
+
129
+ # ruby_frame precedes js_frame: js_stack_trace? fires on any " at ", which a Ruby 3.3+ eval frame
130
+ # ("(eval at app/x.rb:3):1:in 'm'") contains. Keep in sync with frameExtractors in Go.
131
+ %i[godot_frame ruby_frame js_frame go_frame lua_frame].each do |extractor|
132
+ frame, matched = send(extractor, stack_trace)
133
+ return frame if matched
134
+ end
135
+ normalize_top_frame_legacy(stack_trace)
136
+ end
137
+
138
+ # Byte-for-byte the pre-extractor-chain behavior: prefer a JS-style "at"
139
+ # line, else fall back to the first non-empty line.
140
+ def normalize_top_frame_legacy(stack_trace)
141
+ return "" if stack_trace.nil? || stack_trace.empty?
142
+
143
+ lines = stack_trace.strip.split("\n")
144
+ lines.each do |line|
145
+ line = line.strip
146
+ next if line.empty?
147
+ return strip_line_col(line) if line.start_with?("at ") || line.include?(" at ")
148
+ end
149
+ lines.each do |line|
150
+ line = line.strip
151
+ return strip_line_col(line) unless line.empty?
152
+ end
153
+ ""
154
+ end
155
+
156
+ def godot_stack_trace?(stack_trace)
157
+ stack_trace.include?("GDScript backtrace") || stack_trace.include?("res://")
158
+ end
159
+
160
+ # Prefers the first res:// (game script) line over any engine C++ frame
161
+ # that precedes it, skipping res://test/ so the SDK's own test-suite
162
+ # frames don't win.
163
+ def godot_frame(stack_trace)
164
+ return [ "", false ] unless godot_stack_trace?(stack_trace)
165
+
166
+ stack_trace.split("\n").each do |line|
167
+ trimmed = line.strip
168
+ next if trimmed.empty?
169
+ if trimmed.include?("res://") && !trimmed.include?("res://test/")
170
+ return [ strip_line_col(trimmed), true ]
171
+ end
172
+ end
173
+ [ "", false ]
174
+ end
175
+
176
+ def js_stack_trace?(stack_trace)
177
+ stack_trace.split("\n").any? do |line|
178
+ t = line.strip
179
+ t.start_with?("at ") || t.include?(" at ")
180
+ end
181
+ end
182
+
183
+ # Prefers the first "at fn (file:line:col)" frame that isn't <anonymous>
184
+ # or under node_modules.
185
+ def js_frame(stack_trace)
186
+ return [ "", false ] unless js_stack_trace?(stack_trace)
187
+
188
+ stack_trace.split("\n").each do |line|
189
+ t = line.strip
190
+ next if t.empty?
191
+ next unless t.start_with?("at ") || t.include?(" at ")
192
+ next if t.include?("<anonymous>") || t.include?("node_modules")
193
+ return [ strip_line_col(t), true ]
194
+ end
195
+ [ "", false ]
196
+ end
197
+
198
+ def go_stack_trace?(stack_trace)
199
+ stack_trace.split("\n").any? { |line| GOROUTINE_HEADER_PATTERN.match?(line.strip) }
200
+ end
201
+
202
+ # Walks the alternating function-name / file:line(+offset) pairs a Go
203
+ # panic trace prints, preferring the first file:line frame whose function
204
+ # line isn't in the "runtime." package.
205
+ def go_frame(stack_trace)
206
+ return [ "", false ] unless go_stack_trace?(stack_trace)
207
+
208
+ last_func = ""
209
+ stack_trace.split("\n").each do |raw|
210
+ trimmed = raw.strip
211
+ next if trimmed.empty?
212
+ next if GOROUTINE_HEADER_PATTERN.match?(trimmed)
213
+
214
+ unless raw.start_with?("\t") || raw.start_with?(" ")
215
+ last_func = trimmed
216
+ next
217
+ end
218
+ next unless GO_FILE_LINE_PATTERN.match?(trimmed)
219
+ next if last_func.start_with?("runtime.")
220
+
221
+ m = GO_FILE_LINE_PATTERN.match(trimmed)
222
+ return [ m[1], true ]
223
+ end
224
+ [ "", false ]
225
+ end
226
+
227
+ def lua_stack_trace?(stack_trace)
228
+ stack_trace.include?("stack traceback:")
229
+ end
230
+
231
+ # Prefers the first traceback line that isn't a "[C]:" native frame, and
232
+ # strips the line number out of its *middle* — Lua frames read
233
+ # "path.lua:NN: in function 'x'", so the trailing-anchored LINE_COL_PATTERN
234
+ # can't touch it.
235
+ def lua_frame(stack_trace)
236
+ return [ "", false ] unless lua_stack_trace?(stack_trace)
237
+
238
+ in_traceback = false
239
+ stack_trace.split("\n").each do |line|
240
+ trimmed = line.strip
241
+ next if trimmed.empty?
242
+ if trimmed.start_with?("stack traceback:")
243
+ in_traceback = true
244
+ next
245
+ end
246
+ next unless in_traceback
247
+ next if trimmed.start_with?("[C]:")
248
+
249
+ m = LUA_MID_LINE_COL_PATTERN.match(trimmed)
250
+ return [ trimmed, true ] if m.nil?
251
+
252
+ return [ trimmed[0...m.begin(0)] + ":" + trimmed[m.end(0)..], true ]
253
+ end
254
+ [ "", false ]
255
+ end
256
+
257
+ def ruby_stack_trace?(stack_trace)
258
+ stack_trace.split("\n").any? { |line| RUBY_FRAME_PATTERN.match?(line.strip) }
259
+ end
260
+
261
+ # Prefers the first frame under app/ or lib/ over a [gem]/[ruby]/[other] frame and strips the
262
+ # line number out of its middle. A trace with no in-app frame still matches: the first frame is
263
+ # returned with its line stripped.
264
+ def ruby_frame(stack_trace)
265
+ return [ "", false ] unless ruby_stack_trace?(stack_trace)
266
+
267
+ first = nil
268
+ stack_trace.split("\n").each do |line|
269
+ trimmed = line.strip
270
+ next unless RUBY_FRAME_PATTERN.match?(trimmed)
271
+
272
+ frame = if RUBY_ERB_LINE_PATTERN.match?(trimmed)
273
+ trimmed.sub(RUBY_ERB_LINE_PATTERN) { $1 }
274
+ else
275
+ trimmed.sub(RUBY_LINE_PATTERN) { "#{$1}#{$2}" }
276
+ end
277
+ first ||= frame
278
+ return [ frame, true ] if frame.start_with?("app/") || frame.start_with?("lib/")
279
+ end
280
+ [ first, true ]
281
+ end
282
+
283
+ def strip_line_col(line)
284
+ line.sub(LINE_COL_PATTERN) { $1 }
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Decides which player an error or event belongs to. In order: the user the app (or the Warden
5
+ # hook) put on the scope, then the configured user_resolver for the request's controller, then
6
+ # a fixed bucket: anonymous_player_id inside a request, system_player_id outside any (jobs,
7
+ # rake tasks, boot).
8
+ class Identity
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ def player_id(scope:, controller: nil)
14
+ user = scope.user.to_s
15
+ return user unless user.empty?
16
+
17
+ resolve(controller) || bucket(scope: scope, controller: controller)
18
+ end
19
+
20
+ # Who the work belongs to when no user is known: anonymous inside a request, system outside.
21
+ def bucket(scope:, controller: nil)
22
+ controller || scope.request? ? @config.anonymous_player_id : @config.system_player_id
23
+ end
24
+
25
+ private
26
+
27
+ # Must never break the request or job it runs in, and must never touch the database: the
28
+ # resolver reads what the controller already loaded. A resolver that raises is a bug in the
29
+ # app's configuration, so it is logged at error level, and the event goes to the anonymous
30
+ # bucket rather than being lost.
31
+ def resolve(controller)
32
+ return nil unless controller && @config.user_resolver
33
+
34
+ id = @config.user_resolver.call(controller)
35
+ id.nil? || id.to_s.empty? ? nil : id.to_s
36
+ rescue StandardError => e
37
+ @config.logger.error("[fractal] user_resolver raised #{e.class}; using the anonymous player")
38
+ nil
39
+ end
40
+ end
41
+
42
+ # Rails' execution context (controller, job) for the work in progress, or {} outside Rails.
43
+ module ExecutionContext
44
+ module_function
45
+
46
+ def current
47
+ return {} unless defined?(::ActiveSupport::ExecutionContext)
48
+
49
+ ::ActiveSupport::ExecutionContext.to_h
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Registered with Rails.error.subscribe. Reports everything it is given: Rails already leaves
5
+ # out the rescue-responded request errors (404s and the like), and an unfiltered job failure
6
+ # such as a RecordNotFound is a genuine bug. Needs Rails 7.1+, which passes `source:`.
7
+ class ErrorSubscriber
8
+ def report(error, handled:, severity:, context:, source:)
9
+ Fractal.errors.report(error, handled: handled, severity: severity, context: context, source: source)
10
+ rescue Exception => e # rubocop:disable Lint/RescueException
11
+ Fractal.client.config.logger&.error("[fractal] subscriber failed: #{e.class}")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Outermost Rack middleware. It starts the request's scope (so the session and the client
5
+ # address can be read lazily from the env) and reports what happens outside
6
+ # ActionDispatch::Executor: Rack::Cors, HostAuthorization, Sendfile, Static, Propshaft, plus
7
+ # anything that propagates out when show_exceptions is off. The Executor already reports
8
+ # everything from ShowExceptions inward via Rails.error, and Rails.error.report is idempotent
9
+ # per exception, so nothing is sent twice. It re-raises the original exception untouched.
10
+ class RackMiddleware
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ Fractal::Scope.start(env)
17
+ @app.call(env)
18
+ rescue Exception => error # rubocop:disable Lint/RescueException
19
+ capture(error, env)
20
+ raise
21
+ ensure
22
+ Fractal::Scope.clear
23
+ end
24
+
25
+ private
26
+
27
+ def capture(error, env)
28
+ return if rescue_response?(error, env)
29
+
30
+ ::Rails.error.report(error, handled: false, source: "fractal.middleware")
31
+ rescue Exception => e # rubocop:disable Lint/RescueException
32
+ Fractal.client.config.logger&.error("[fractal] middleware failed: #{e.class}")
33
+ end
34
+
35
+ # Rails skips these for requests (a 404 is not a bug). It has not made that call yet for an
36
+ # exception that never reached ShowExceptions, so apply it here.
37
+ def rescue_response?(error, env)
38
+ ActionDispatch::ExceptionWrapper.new(env["action_dispatch.backtrace_cleaner"], error).rescue_response?
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middleware"
4
+ require_relative "error_subscriber"
5
+ require_relative "warden_hooks"
6
+
7
+ module Fractal
8
+ # Wires the gem into a Rails app (7.1 or later): the outermost middleware, the Rails.error
9
+ # subscriber, scope cleanup after every request and job, and Warden's sign-in hooks when
10
+ # Warden (Devise) is loaded. Configuration stays in the app's own initializer.
11
+ #
12
+ # Nothing in the gem is named Fractal::Rails: an app whose own module is Fractal (Fractal's app
13
+ # is) declares `class Application < Rails::Application` inside `module Fractal`, where such a
14
+ # constant would shadow ::Rails and break the boot.
15
+ class Railtie < ::Rails::Railtie
16
+ # After the app's own initializers: one of them may also insert at 0 (Rack::Cors usually
17
+ # does), and the gem's middleware has to end up outside everything.
18
+ initializer "fractal.middleware", after: :load_config_initializers do |app|
19
+ app.config.middleware.insert_before 0, Fractal::RackMiddleware
20
+ end
21
+
22
+ initializer "fractal.error_subscriber" do
23
+ ::Rails.error.subscribe(Fractal::ErrorSubscriber.new)
24
+ end
25
+
26
+ # The executor wraps every request and every job. Its error report runs before completion,
27
+ # so a job's set_user and tags are still on the scope when its failure is captured.
28
+ initializer "fractal.scope_cleanup" do |app|
29
+ app.executor.to_complete { Fractal::Scope.clear }
30
+ end
31
+
32
+ config.after_initialize do
33
+ Fractal::WardenHooks.install if defined?(::Warden::Manager)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractal
4
+ # Warden (and so Devise) tells us who the user is as soon as the app asks, with the user
5
+ # already loaded: no extra query, nothing for the app to wire. Every set_user puts the user's
6
+ # player id on the scope; a sign-in also tracks `identify`, which links the pre-login session to
7
+ # the user (see Fractal.identify). A sign-in is a set_user that is not a :fetch and is stored in
8
+ # the session: a stateless strategy (token or HTTP auth) authenticates with store: false on
9
+ # every request, and is not a sign-in.
10
+ #
11
+ # The hooks run inside the app's authentication, so they must never raise into it.
12
+ module WardenHooks
13
+ module_function
14
+
15
+ def install
16
+ return if @installed
17
+
18
+ @installed = true
19
+ ::Warden::Manager.after_set_user { |user, _auth, opts| on_set_user(user, opts) }
20
+ ::Warden::Manager.before_logout { |_user, _auth, _opts| Fractal.set_user(nil) }
21
+ end
22
+
23
+ def on_set_user(user, opts)
24
+ return unless Fractal.configured? && user
25
+
26
+ config = Fractal.client.config
27
+ player_id = config.player_id_for_user.call(user).to_s
28
+ return if player_id.empty?
29
+
30
+ if opts && opts[:event] != :fetch && opts[:store] != false && config.identify_on_login
31
+ Fractal.identify(player_id)
32
+ else
33
+ Fractal.set_user(player_id)
34
+ end
35
+ rescue Exception => e # rubocop:disable Lint/RescueException
36
+ Fractal.client.config.logger&.error("[fractal] warden hook failed: #{e.class}")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Fractal
6
+ # What is known about the unit of work in progress (one request or one job): who the user is,
7
+ # tags, breadcrumbs, the session and the Rack env. Shared by errors and analytics events, and
8
+ # never global: it lives in fiber-local storage, set up at the start of a request by the
9
+ # middleware and cleared when the request or job completes.
10
+ #
11
+ # Code outside any request or job gets a scope too (created on first use), so Fractal.set_tag in
12
+ # a rake task still reaches that task's errors; nothing clears it but the next Scope.clear.
13
+ class Scope
14
+ KEY = :__fractal_scope
15
+ MAX_BREADCRUMBS = 50
16
+ MAX_TAGS = 50
17
+
18
+ attr_reader :env, :tags, :breadcrumbs
19
+ attr_accessor :user, :session_token, :client_ip
20
+
21
+ class << self
22
+ def current
23
+ Thread.current[KEY] ||= new
24
+ end
25
+
26
+ # Starts a fresh scope for a request. env is the Rack env, read lazily for the session and
27
+ # the client address.
28
+ def start(env = nil)
29
+ Thread.current[KEY] = new(env)
30
+ end
31
+
32
+ def clear
33
+ Thread.current[KEY] = nil
34
+ end
35
+
36
+ # Runs the block in a fresh scope and restores the previous one afterwards.
37
+ def isolated(env = nil)
38
+ previous = Thread.current[KEY]
39
+ Thread.current[KEY] = new(env)
40
+ yield
41
+ ensure
42
+ Thread.current[KEY] = previous
43
+ end
44
+ end
45
+
46
+ def initialize(env = nil)
47
+ @env = env
48
+ @tags = {}
49
+ @breadcrumbs = []
50
+ @user = nil
51
+ @session_token = nil
52
+ @client_ip = nil
53
+ end
54
+
55
+ def request? = !@env.nil?
56
+
57
+ def set_tag(key, value)
58
+ key = key.to_s
59
+ return if !@tags.key?(key) && @tags.size >= MAX_TAGS
60
+
61
+ @tags[key] = value
62
+ end
63
+
64
+ def add_breadcrumb(crumb)
65
+ @breadcrumbs << crumb
66
+ @breadcrumbs.shift while @breadcrumbs.size > MAX_BREADCRUMBS
67
+ end
68
+ end
69
+
70
+ # The default `session_resolver`: a hash of the Rack session id, so one browser session is one
71
+ # Fractal session. Warden and Devise give the session a new id at sign-in and sign-out, so the
72
+ # logged-out and logged-in halves of a visit are two sessions, joined by the `identify` event.
73
+ #
74
+ # Reads a session only when the request already has one. Loading an absent session would make a
75
+ # cookie store issue a Set-Cookie on the customer's response, for a visitor who had none.
76
+ module SessionResolver
77
+ module_function
78
+
79
+ def from_rack_session(env)
80
+ session = env && env["rack.session"]
81
+ return nil unless session
82
+ return nil unless loaded?(session) || exists?(session)
83
+
84
+ id = session.respond_to?(:id) ? session.id : nil
85
+ id = id.public_id if id.respond_to?(:public_id)
86
+ return nil if id.nil? || id.to_s.empty?
87
+
88
+ "web_#{Digest::SHA256.hexdigest(id.to_s)[0, 32]}"
89
+ end
90
+
91
+ def loaded?(session) = session.respond_to?(:loaded?) && session.loaded?
92
+
93
+ def exists?(session) = session.respond_to?(:exists?) && session.exists?
94
+ end
95
+ end