Module: Restless::Fingerprint
- Defined in:
- lib/restless/fingerprint.rb
Overview
CONTRACT.md section 5. A stable identifier for an HTTP error response, computed at capture time and shipped with the log.
The ingest stores it, the dashboard groups by it, a customer attaches a recovery message to a group, and the SDK injects that message into matching responses. Nothing re-derives it, so every SDK must agree.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- CODE_FIELDS =
FP-016. Checked in this order, matched EXACTLY (case-sensitively, no REDACT-010 normalization).
%w[code error_code errorCode type].freeze
- NESTED_PATHS =
[ %w[error code], %w[error type], %w[error error_code] ].freeze
- CODE_RE =
FP-015. 1 to 64 code points, identifier-shaped. Keeps
card_declinedandAUTH_MISMATCH, rejectsYour card was declined.and bare UUIDs.\A...\zrather than^...$(PRIM-005): Ruby's$also matches before a trailing newline, so"boom\n"would look like a code here and not in JavaScript. /\A[a-zA-Z][a-zA-Z0-9_.\-]*\z/.freeze
- SEG_UUID =
FP-030. A single path segment that collapses to
:id. Each pattern is fully anchored, so this is a whole-segment test rather than a scan.The hex ranges are written out rather than using the
iflag: Ruby's case-insensitive matching is Unicode case folding, which is a wider relation than the ASCII-only canonicalization a non-uJavaScript regex performs. /\A[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}\z/.freeze
- SEG_NUMERIC =
/\A[0-9]+\z/.freeze
- SEG_LONG_HEX =
/\A[0-9a-fA-F]{16,}\z/.freeze
- PROJECT_DIRS =
FP-042. Project-directory markers.
%w[src lib app api routes controllers handlers].freeze
- WS =
FP-020. The classes are enumerated (PRIM-001, PRIM-002) rather than spelled
\w/\s, because those mean different things in different engines and this key has to be byte-identical in every SDK. Text::WS_CLASS
- WORD =
Text::WORD_CLASS
- RE_URL =
Regexp.new("https?://[^#{WS}]+").freeze
- RE_EMAIL =
Regexp.new("[^#{WS}]+@[^#{WS}]+\\.[^#{WS}]+").freeze
- RE_QUOTED =
/['"`][^'"`]*['"`]/.freeze
- RE_PUNCT =
Regexp.new("[^#{WORD}#{WS}\\-]").freeze
- RE_WS_RUN =
Regexp.new("[#{WS}]+").freeze
- RE_DIGIT_WORD =
FP-020 step 5, and the one regex in this file that cannot be written against a UTF-8 string.
Ruby's
\wis ASCII-only (correct here) but its\bis NOT: Onigmo defines the boundary against the Unicode word property, so"eA1"with a leading accented letter has no boundary before theAand the whole digit-word survives, where JavaScript, Go and Python-with-re.ASCII all strip it. Applying the match to the UTF-8 BYTES restores JavaScript's semantics exactly: every non-ASCII character becomes a run of non-word bytes, which is what a non-uJS regex sees in UTF-16 too. The pattern can only ever match ASCII bytes, so byte slicing and character slicing coincide and the result is still well-formed UTF-8. Regexp.new("\\b[#{WORD}\\-]*[0-9][#{WORD}\\-]*\\b").freeze
Class Method Summary collapse
-
.compute(status:, method: nil, route: nil, response_headers: nil, response_body: nil, stack_frame: nil) ⇒ Object
FP-010.
-
.extract_message(body) ⇒ Object
FP-018.
- .id_segment?(segment) ⇒ Boolean
-
.looks_like_code?(value) ⇒ Boolean
FP-015.
-
.normalize_message(msg) ⇒ Object
FP-020.
-
.normalize_route(route) ⇒ Object
FP-030..FP-032.
-
.project_relative(file) ⇒ Object
FP-042.
- .read_body_code(body) ⇒ Object
-
.read_header_code(headers) ⇒ Object
FP-017.
-
.strip_digit_words(str) ⇒ Object
FP-021.
Class Method Details
.compute(status:, method: nil, route: nil, response_headers: nil, response_body: nil, stack_frame: nil) ⇒ Object
FP-010. Strategies are tried in this exact order; the first that yields a key wins.
stack_frame is the already-extracted {file:, fn:} for the frame
nearest the throw site (FP-043). Frame PARSING is per-language
(FP-044) and lives in Restless::StackFrames, so this function stays
dialect-free.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/restless/fingerprint.rb', line 81 def compute(status:, method: nil, route: nil, response_headers: nil, response_body: nil, stack_frame: nil) method = "GET" if method.nil? || method.empty? # FP-011 # FP-012. 404 is intercepted BEFORE the code-based strategies: a generic # `not_found` code is the same on every route, so grouping 404s by code # is useless for recovery. if status == 404 normalized = route.nil? || route.empty? ? "" : normalize_route(route) # FP-014: the parameter test runs on the NORMALIZED route. if normalized.include?(":") || normalized.include?("{") return Result.new( "resource", "404:resource", "404 on a parameterized route (#{method} #{normalized}); " \ "the addressed resource was not found" ) end reason = if normalized.empty? "404 on a path that matched no route; the endpoint does not exist" else "404 on #{method} #{normalized}; no resource at this path" end return Result.new("endpoint", "404:endpoint", reason) end # 1. Explicit header. Fully deterministic; the customer opted in. header_code = read_header_code(response_headers) if header_code return Result.new("header", "#{status}:#{header_code}", %(x-restless-error-code header: "#{header_code}")) end # 2. Code-like field in the body. Stripe/AWS/Twilio-shaped APIs land here. body_code = read_body_code(response_body) if body_code return Result.new("body-code", "#{status}:#{body_code}", %(code field in body: "#{body_code}")) end # 3. Stack trace (5xx with a thrown exception). File + function only: # FP-041 forbids a line number, so adding a comment above a `raise` # cannot split an error group. if status >= 500 && stack_frame return Result.new( "stack", "#{status}:#{stack_frame[:file]}:#{stack_frame[:fn]}", "top user frame: #{stack_frame[:fn]} in #{stack_frame[:file]}" ) end # 4. Normalized message + templated route. normalized_route = normalize_route(route) msg = ((response_body)) unless msg.empty? return Result.new("message", "#{status}:#{method}:#{normalized_route}:#{msg}", %(message normalized to "#{msg}")) end # 5. Status + route only. Coarse, but it groups all unhandled responses. Result.new("route-only", "#{status}:#{method}:#{normalized_route}", "no usable code or message; falling back to status + route") end |
.extract_message(body) ⇒ Object
FP-018. Body itself if it is a string; then message; then error if
it is a string; then error.message. Anything else yields no message.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/restless/fingerprint.rb', line 180 def (body) return "" if body.nil? || body == false return body if body.is_a?(String) return "" unless body.is_a?(Hash) = body["message"] return if .is_a?(String) nested = body["error"] return nested if nested.is_a?(String) if nested.is_a?(Hash) && nested["message"].is_a?(String) return nested["message"] end "" end |
.id_segment?(segment) ⇒ Boolean
243 244 245 246 247 |
# File 'lib/restless/fingerprint.rb', line 243 def id_segment?(segment) SEG_UUID.match?(segment) || SEG_NUMERIC.match?(segment) || SEG_LONG_HEX.match?(segment) end |
.looks_like_code?(value) ⇒ Boolean
FP-015.
171 172 173 174 175 176 |
# File 'lib/restless/fingerprint.rb', line 171 def looks_like_code?(value) value.is_a?(String) && !value.empty? && value.length <= 64 && CODE_RE.match?(value) end |
.normalize_message(msg) ⇒ Object
FP-020. The 12 steps, in exactly this order.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/restless/fingerprint.rb', line 198 def (msg) return "" if msg.nil? || msg.empty? s = Text.full_lower(msg) # 1 s = s.gsub(RE_URL, " ") # 2 s = s.gsub(RE_EMAIL, " ") # 3 s = s.gsub(RE_QUOTED, " ") # 4 s = strip_digit_words(s) # 5 s = s.gsub(RE_PUNCT, " ") # 6 s = s.gsub(RE_WS_RUN, " ") # 7 s = Text.ws_trim(s) # 8 s.split(/ /, -1) # 9 (never `split(" ")`: awk mode) .reject { |w| w.length <= 1 } # 10 .first(6) # 11 .join("-") # 12 end |
.normalize_route(route) ⇒ Object
FP-030..FP-032.
228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'lib/restless/fingerprint.rb', line 228 def normalize_route(route) return "/" if route.nil? || route.empty? # `split("/", -1)`: Ruby drops trailing empty fields without the # negative limit, so `/users/` would lose its trailing segment and stop # matching JavaScript. segments = route.split("/", -1) # FP-031: index 0 is the text BEFORE the first "/" and is never # normalized, which keeps a bare "123" route untouched. (1...segments.length).each do |i| segments[i] = ":id" if id_segment?(segments[i]) end segments.join("/") end |
.project_relative(file) ⇒ Object
FP-042. Strip the machine-specific path prefix down to a project-relative path, so the same source file produces the same key on a laptop and in production.
The LAST project directory wins, not the first, and the difference is the whole point:
/Users/dev/proj/src/db/users.rb -> src/db/users.rb
/app/src/db/users.rb -> src/db/users.rb
/opt/render/project/src/db/users.rb -> src/db/users.rb
A first-match rule returns app/src/db/users.rb for the middle one,
because the deployment root IS the first match. Docker's conventional
WORKDIR /app and Heroku both root there, so first-match made production
disagree with a laptop for the same file across the most common
containerized layout there is, defeating the only thing this function
exists to do.
The trade is that a nested layout (/proj/src/a/src/x.rb) collapses to
src/x.rb rather than src/a/src/x.rb. That is far rarer than an /app
root and is still machine-independent, which is the property being
protected.
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/restless/fingerprint.rb', line 271 def project_relative(file) # `split("/", -1)`: Ruby drops trailing empty fields without the negative # limit, so `/proj/src/` would come back as ["", "proj", "src"] and take # the fallback branch where JavaScript, which keeps the empty field, # matches `src` and returns "src/". segments = file.split("/", -1) # Stop before the final component: a project dir has to have something # after it to be a directory at all. A negative start makes `downto` # yield nothing, which is correct for a bare filename. (segments.length - 2).downto(0) do |i| return segments[i..-1].join("/") if PROJECT_DIRS.include?(segments[i]) end segments.last(2).join("/") end |
.read_body_code(body) ⇒ Object
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/restless/fingerprint.rb', line 153 def read_body_code(body) return nil unless body.is_a?(Hash) CODE_FIELDS.each do |field| value = body[field] return value if looks_like_code?(value) end NESTED_PATHS.each do |path| value = body path.each { |segment| value = value.is_a?(Hash) ? value[segment] : nil } return value if looks_like_code?(value) end nil end |
.read_header_code(headers) ⇒ Object
FP-017. The header name is matched case-insensitively.
144 145 146 147 148 149 150 151 |
# File 'lib/restless/fingerprint.rb', line 144 def read_header_code(headers) return nil unless headers.is_a?(Hash) lower = {} headers.each { |k, v| lower[Text.full_lower(k.to_s)] = v } value = lower["x-restless-error-code"] looks_like_code?(value) ? value : nil end |
.strip_digit_words(str) ⇒ Object
FP-021. Whole words containing a digit are stripped, not bare digits:
abc123 reduced to abc would still influence the key, so grouping
would break whenever the surrounding id changed.
See RE_DIGIT_WORD for why this runs against the byte string.
220 221 222 223 224 225 |
# File 'lib/restless/fingerprint.rb', line 220 def strip_digit_words(str) str.dup .force_encoding(Encoding::ASCII_8BIT) .gsub(RE_DIGIT_WORD, " ") .force_encoding(Encoding::UTF_8) end |