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,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
|