uncaught 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/lib/uncaught/breadcrumbs.rb +94 -0
- data/lib/uncaught/client.rb +248 -0
- data/lib/uncaught/env_detector.rb +102 -0
- data/lib/uncaught/fingerprint.rb +144 -0
- data/lib/uncaught/integrations/rails.rb +54 -0
- data/lib/uncaught/integrations/sinatra.rb +57 -0
- data/lib/uncaught/prompt_builder.rb +174 -0
- data/lib/uncaught/rate_limiter.rb +65 -0
- data/lib/uncaught/sanitizer.rb +111 -0
- data/lib/uncaught/transport.rb +340 -0
- data/lib/uncaught/types.rb +84 -0
- data/lib/uncaught/version.rb +5 -0
- data/lib/uncaught.rb +122 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f39344c92b50ad3308911adcf49713a5d94595c90f878af2272dff89b4a7b9ab
|
|
4
|
+
data.tar.gz: '029a359e0db5aa4184b5ae341949bd86e657d0704a5cee6086b18c0acbc13455'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d425228bc633b44e6d919bbb2192944f9e089716c8bb1637a2e45e4bcfac2b862ec0357885a3957709a3a6bd12b517a09fc583d6b81bba5fe1987b637f5d5a7c
|
|
7
|
+
data.tar.gz: d4cfb32ae1122dedd164eee47262b609befc4b68a4a2e1600bfc448c1423c4dd55e748b9795c5b00da8943615947a3e53b4da6dd23b8b4df23d688887973e7b4
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uncaught
|
|
4
|
+
# Thread-safe ring buffer for breadcrumbs.
|
|
5
|
+
#
|
|
6
|
+
# - O(1) add
|
|
7
|
+
# - Oldest entries are silently overwritten when capacity is reached.
|
|
8
|
+
# - Returned arrays are always copies -- callers cannot mutate internal state.
|
|
9
|
+
class BreadcrumbStore
|
|
10
|
+
# @param capacity [Integer] Maximum breadcrumbs retained. Defaults to 20.
|
|
11
|
+
def initialize(capacity = 20)
|
|
12
|
+
@capacity = [capacity, 1].max
|
|
13
|
+
@buffer = Array.new(@capacity)
|
|
14
|
+
@head = 0
|
|
15
|
+
@size = 0
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Append a breadcrumb (auto-timestamps).
|
|
20
|
+
#
|
|
21
|
+
# @param type [String]
|
|
22
|
+
# @param category [String]
|
|
23
|
+
# @param message [String]
|
|
24
|
+
# @param data [Hash, nil]
|
|
25
|
+
# @param level [String, nil]
|
|
26
|
+
def add(type:, category:, message:, data: nil, level: nil)
|
|
27
|
+
entry = Breadcrumb.new(
|
|
28
|
+
type: type,
|
|
29
|
+
category: category,
|
|
30
|
+
message: message,
|
|
31
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
32
|
+
data: data,
|
|
33
|
+
level: level
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@buffer[@head] = entry
|
|
38
|
+
@head = (@head + 1) % @capacity
|
|
39
|
+
@size = [@size + 1, @capacity].min
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Return all stored breadcrumbs in chronological order (copies).
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<Breadcrumb>]
|
|
46
|
+
def get_all
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return [] if @size == 0
|
|
49
|
+
|
|
50
|
+
result = []
|
|
51
|
+
start = (@head - @size + @capacity) % @capacity
|
|
52
|
+
@size.times do |i|
|
|
53
|
+
idx = (start + i) % @capacity
|
|
54
|
+
entry = @buffer[idx]
|
|
55
|
+
result << entry.dup if entry
|
|
56
|
+
end
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Return the most recent n breadcrumbs (copies).
|
|
62
|
+
#
|
|
63
|
+
# @param n [Integer]
|
|
64
|
+
# @return [Array<Breadcrumb>]
|
|
65
|
+
def get_last(n)
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
return [] if n <= 0 || @size == 0
|
|
68
|
+
|
|
69
|
+
count = [n, @size].min
|
|
70
|
+
result = []
|
|
71
|
+
count.times do |i|
|
|
72
|
+
idx = (@head - 1 - i + @capacity) % @capacity
|
|
73
|
+
entry = @buffer[idx]
|
|
74
|
+
result.unshift(entry.dup) if entry
|
|
75
|
+
end
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Empty the buffer.
|
|
81
|
+
def clear
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@buffer = Array.new(@capacity)
|
|
84
|
+
@head = 0
|
|
85
|
+
@size = 0
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Integer]
|
|
90
|
+
def size
|
|
91
|
+
@mutex.synchronize { @size }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Uncaught
|
|
7
|
+
SDK_NAME = "uncaught-ruby"
|
|
8
|
+
|
|
9
|
+
# The main SDK client. Captures errors and sends them through the transport
|
|
10
|
+
# pipeline.
|
|
11
|
+
class Client
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
# @param config [Configuration]
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
@breadcrumbs = BreadcrumbStore.new(config.max_breadcrumbs || 20)
|
|
18
|
+
@transport = Uncaught.create_transport(config)
|
|
19
|
+
@rate_limiter = RateLimiter.new(
|
|
20
|
+
global_max: config.max_events_per_minute || 30
|
|
21
|
+
)
|
|
22
|
+
@session_id = SecureRandom.uuid
|
|
23
|
+
@seen_fingerprints = Set.new
|
|
24
|
+
@user = nil
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Capture an error and send it through the transport pipeline.
|
|
29
|
+
#
|
|
30
|
+
# @param error [Exception, String, Hash] The error to capture.
|
|
31
|
+
# @param request [RequestInfo, nil]
|
|
32
|
+
# @param operation [OperationInfo, nil]
|
|
33
|
+
# @param component_stack [String, nil]
|
|
34
|
+
# @param level [String] Severity level. Defaults to "error".
|
|
35
|
+
# @return [String, nil] The event ID, or nil if the event was dropped.
|
|
36
|
+
def capture_error(error, request: nil, operation: nil, component_stack: nil, level: "error")
|
|
37
|
+
return nil unless @config.enabled
|
|
38
|
+
|
|
39
|
+
# --- Normalise error ---
|
|
40
|
+
error_info = normalise_error(error)
|
|
41
|
+
error_info.component_stack = component_stack if component_stack
|
|
42
|
+
|
|
43
|
+
# --- Check ignoreErrors ---
|
|
44
|
+
if should_ignore?(error_info.message)
|
|
45
|
+
debug_log("Event ignored by ignoreErrors filter")
|
|
46
|
+
return nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- Fingerprint ---
|
|
50
|
+
fingerprint = Fingerprint.generate(
|
|
51
|
+
type: error_info.type,
|
|
52
|
+
message: error_info.message,
|
|
53
|
+
stack: error_info.stack
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# --- Rate limit ---
|
|
57
|
+
unless @rate_limiter.should_allow?(fingerprint)
|
|
58
|
+
debug_log("Rate-limited: #{fingerprint}")
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- Collect breadcrumbs ---
|
|
63
|
+
crumbs = @breadcrumbs.get_all
|
|
64
|
+
|
|
65
|
+
# --- Detect environment ---
|
|
66
|
+
environment = EnvDetector.detect
|
|
67
|
+
|
|
68
|
+
# Attach deployment environment from config
|
|
69
|
+
environment.deploy = @config.environment if @config.environment
|
|
70
|
+
environment.framework = @config.framework if @config.framework
|
|
71
|
+
environment.framework_version = @config.framework_version if @config.framework_version
|
|
72
|
+
|
|
73
|
+
# --- Build event ---
|
|
74
|
+
event_id = SecureRandom.uuid
|
|
75
|
+
event = UncaughtEvent.new(
|
|
76
|
+
event_id: event_id,
|
|
77
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
78
|
+
project_key: @config.project_key,
|
|
79
|
+
level: level,
|
|
80
|
+
fingerprint: fingerprint,
|
|
81
|
+
release: @config.release,
|
|
82
|
+
error: error_info,
|
|
83
|
+
breadcrumbs: crumbs,
|
|
84
|
+
request: request,
|
|
85
|
+
operation: operation,
|
|
86
|
+
environment: environment,
|
|
87
|
+
user: build_user_info,
|
|
88
|
+
fix_prompt: "",
|
|
89
|
+
sdk: SdkInfo.new(name: SDK_NAME, version: VERSION)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# --- Sanitise ---
|
|
93
|
+
event = Sanitizer.sanitize(event, @config.sanitize_keys)
|
|
94
|
+
|
|
95
|
+
# --- Build fix prompt ---
|
|
96
|
+
event.fix_prompt = PromptBuilder.build(event)
|
|
97
|
+
|
|
98
|
+
# --- beforeSend hook ---
|
|
99
|
+
if @config.before_send
|
|
100
|
+
result = @config.before_send.call(event)
|
|
101
|
+
if result.nil?
|
|
102
|
+
debug_log("Event dropped by beforeSend")
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
event = result
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- Send ---
|
|
109
|
+
@transport.send_event(event)
|
|
110
|
+
debug_log("Captured event: #{event_id} (#{fingerprint})")
|
|
111
|
+
|
|
112
|
+
# --- Track seen fingerprints ---
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@seen_fingerprints.add(fingerprint)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
event_id
|
|
118
|
+
rescue => e
|
|
119
|
+
debug_log("capture_error failed: #{e.message}")
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Capture a plain message (not backed by an Exception instance).
|
|
124
|
+
#
|
|
125
|
+
# @param message [String]
|
|
126
|
+
# @param level [String] Defaults to "info".
|
|
127
|
+
# @return [String, nil] The event ID, or nil if the event was dropped.
|
|
128
|
+
def capture_message(message, level: "info")
|
|
129
|
+
capture_error(RuntimeError.new(message), level: level)
|
|
130
|
+
rescue => e
|
|
131
|
+
debug_log("capture_message failed: #{e.message}")
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Add a breadcrumb to the ring buffer.
|
|
136
|
+
#
|
|
137
|
+
# @param type [String]
|
|
138
|
+
# @param category [String]
|
|
139
|
+
# @param message [String]
|
|
140
|
+
# @param data [Hash, nil]
|
|
141
|
+
# @param level [String, nil]
|
|
142
|
+
def add_breadcrumb(type:, category:, message:, data: nil, level: nil)
|
|
143
|
+
return unless @config.enabled
|
|
144
|
+
|
|
145
|
+
@breadcrumbs.add(
|
|
146
|
+
type: type,
|
|
147
|
+
category: category,
|
|
148
|
+
message: message,
|
|
149
|
+
data: data,
|
|
150
|
+
level: level
|
|
151
|
+
)
|
|
152
|
+
rescue => e
|
|
153
|
+
debug_log("add_breadcrumb failed: #{e.message}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Set user context that will be attached to subsequent events.
|
|
157
|
+
#
|
|
158
|
+
# @param user [UserInfo, Hash, nil]
|
|
159
|
+
def set_user(user)
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
if user.nil?
|
|
162
|
+
@user = nil
|
|
163
|
+
elsif user.is_a?(UserInfo)
|
|
164
|
+
@user = user.dup
|
|
165
|
+
elsif user.is_a?(Hash)
|
|
166
|
+
@user = UserInfo.new(
|
|
167
|
+
id: user[:id] || user["id"],
|
|
168
|
+
email: user[:email] || user["email"],
|
|
169
|
+
username: user[:username] || user["username"]
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
rescue => e
|
|
174
|
+
debug_log("set_user failed: #{e.message}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Flush all queued events to the transport.
|
|
178
|
+
def flush
|
|
179
|
+
@transport.flush
|
|
180
|
+
rescue => e
|
|
181
|
+
debug_log("flush failed: #{e.message}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def normalise_error(error)
|
|
187
|
+
case error
|
|
188
|
+
when Exception
|
|
189
|
+
ErrorInfo.new(
|
|
190
|
+
message: error.message || error.to_s,
|
|
191
|
+
type: error.class.name || "Error",
|
|
192
|
+
stack: (error.backtrace || []).join("\n")
|
|
193
|
+
)
|
|
194
|
+
when String
|
|
195
|
+
ErrorInfo.new(
|
|
196
|
+
message: error,
|
|
197
|
+
type: "StringError",
|
|
198
|
+
stack: caller.join("\n")
|
|
199
|
+
)
|
|
200
|
+
when Hash
|
|
201
|
+
ErrorInfo.new(
|
|
202
|
+
message: (error[:message] || error["message"] || error.to_s).to_s,
|
|
203
|
+
type: (error[:type] || error["type"] || "HashError").to_s,
|
|
204
|
+
stack: (error[:stack] || error["stack"] || "").to_s
|
|
205
|
+
)
|
|
206
|
+
else
|
|
207
|
+
ErrorInfo.new(
|
|
208
|
+
message: error.to_s,
|
|
209
|
+
type: "UnknownError"
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def should_ignore?(message)
|
|
215
|
+
return false unless @config.ignore_errors && !@config.ignore_errors.empty?
|
|
216
|
+
|
|
217
|
+
@config.ignore_errors.any? do |pattern|
|
|
218
|
+
case pattern
|
|
219
|
+
when String
|
|
220
|
+
message.include?(pattern)
|
|
221
|
+
when Regexp
|
|
222
|
+
pattern.match?(message)
|
|
223
|
+
else
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_user_info
|
|
230
|
+
@mutex.synchronize do
|
|
231
|
+
if @user
|
|
232
|
+
UserInfo.new(
|
|
233
|
+
id: @user.id,
|
|
234
|
+
email: @user.email,
|
|
235
|
+
username: @user.username,
|
|
236
|
+
session_id: @session_id
|
|
237
|
+
)
|
|
238
|
+
else
|
|
239
|
+
UserInfo.new(session_id: @session_id)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def debug_log(msg)
|
|
245
|
+
$stderr.puts("[uncaught] #{msg}") if @config.debug
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uncaught
|
|
4
|
+
module EnvDetector
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Detect the current runtime environment.
|
|
8
|
+
#
|
|
9
|
+
# @return [EnvironmentInfo]
|
|
10
|
+
def detect
|
|
11
|
+
info = EnvironmentInfo.new
|
|
12
|
+
|
|
13
|
+
info.runtime = "ruby"
|
|
14
|
+
info.runtime_version = RUBY_VERSION
|
|
15
|
+
info.platform = RUBY_PLATFORM
|
|
16
|
+
info.os = detect_os
|
|
17
|
+
|
|
18
|
+
# Detect framework
|
|
19
|
+
detect_framework(info)
|
|
20
|
+
|
|
21
|
+
# Detect hosting platform
|
|
22
|
+
detect_platform(info)
|
|
23
|
+
|
|
24
|
+
info
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# -------------------------------------------------------------------------
|
|
28
|
+
# Internal helpers
|
|
29
|
+
# -------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def detect_os
|
|
32
|
+
case RUBY_PLATFORM
|
|
33
|
+
when /darwin/i
|
|
34
|
+
"macOS"
|
|
35
|
+
when /mswin|mingw|cygwin/i
|
|
36
|
+
"Windows"
|
|
37
|
+
when /linux/i
|
|
38
|
+
"Linux"
|
|
39
|
+
when /freebsd/i
|
|
40
|
+
"FreeBSD"
|
|
41
|
+
else
|
|
42
|
+
RUBY_PLATFORM
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detect_framework(info)
|
|
47
|
+
# Rails
|
|
48
|
+
if defined?(::Rails)
|
|
49
|
+
info.framework = "Rails"
|
|
50
|
+
info.framework_version = ::Rails::VERSION::STRING if defined?(::Rails::VERSION::STRING)
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sinatra
|
|
55
|
+
if defined?(::Sinatra)
|
|
56
|
+
info.framework = "Sinatra"
|
|
57
|
+
info.framework_version = ::Sinatra::VERSION if defined?(::Sinatra::VERSION)
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Hanami
|
|
62
|
+
if defined?(::Hanami)
|
|
63
|
+
info.framework = "Hanami"
|
|
64
|
+
info.framework_version = ::Hanami::VERSION if defined?(::Hanami::VERSION)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Grape
|
|
69
|
+
if defined?(::Grape)
|
|
70
|
+
info.framework = "Grape"
|
|
71
|
+
info.framework_version = ::Grape::VERSION if defined?(::Grape::VERSION)
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Roda
|
|
76
|
+
if defined?(::Roda)
|
|
77
|
+
info.framework = "Roda"
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def detect_platform(info)
|
|
83
|
+
if ENV["HEROKU_APP_NAME"]
|
|
84
|
+
info.platform = "heroku"
|
|
85
|
+
elsif ENV["VERCEL"]
|
|
86
|
+
info.platform = "vercel"
|
|
87
|
+
elsif ENV["RAILWAY_PROJECT_ID"]
|
|
88
|
+
info.platform = "railway"
|
|
89
|
+
elsif ENV["FLY_APP_NAME"]
|
|
90
|
+
info.platform = "fly"
|
|
91
|
+
elsif ENV["AWS_LAMBDA_FUNCTION_NAME"]
|
|
92
|
+
info.platform = "aws-lambda"
|
|
93
|
+
elsif ENV["GOOGLE_CLOUD_PROJECT"]
|
|
94
|
+
info.platform = "gcp"
|
|
95
|
+
elsif ENV["RENDER_SERVICE_ID"]
|
|
96
|
+
info.platform = "render"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method :detect_os, :detect_framework, :detect_platform
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uncaught
|
|
4
|
+
module Fingerprint
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Generate a stable fingerprint for an error so that duplicate occurrences
|
|
8
|
+
# of the same bug are grouped together.
|
|
9
|
+
#
|
|
10
|
+
# The fingerprint is an 8-character hex string derived from:
|
|
11
|
+
# 1. The error type (or "Error" if absent).
|
|
12
|
+
# 2. The normalised error message (volatile parts stripped).
|
|
13
|
+
# 3. The top 3 stack frames (file + function name, no line/col numbers).
|
|
14
|
+
#
|
|
15
|
+
# @param type [String, nil] Error class name.
|
|
16
|
+
# @param message [String, nil] Error message.
|
|
17
|
+
# @param stack [String, nil] Stack trace string.
|
|
18
|
+
# @return [String] 8-character lowercase hex fingerprint.
|
|
19
|
+
def generate(type: nil, message: nil, stack: nil)
|
|
20
|
+
normalised_message = normalise_message(message || "")
|
|
21
|
+
frames = extract_top_frames(stack || "", 3)
|
|
22
|
+
input = [type || "Error", normalised_message, *frames].join("\n")
|
|
23
|
+
djb2(input)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -------------------------------------------------------------------------
|
|
27
|
+
# DJB2 hash -> 8-character lowercase hex string.
|
|
28
|
+
#
|
|
29
|
+
# Matches the TypeScript implementation exactly:
|
|
30
|
+
# let hash = 5381;
|
|
31
|
+
# hash = ((hash << 5) + hash + charCode) | 0; // signed 32-bit
|
|
32
|
+
# return (hash >>> 0).toString(16).padStart(8, '0');
|
|
33
|
+
#
|
|
34
|
+
# Ruby integers are arbitrary precision, so we must mask to 32 bits after
|
|
35
|
+
# every operation to emulate JavaScript's `| 0` (signed 32-bit) and
|
|
36
|
+
# `>>> 0` (unsigned 32-bit) semantics.
|
|
37
|
+
# -------------------------------------------------------------------------
|
|
38
|
+
def djb2(str)
|
|
39
|
+
hash = 5381
|
|
40
|
+
# JavaScript's charCodeAt() returns UTF-16 code units, not UTF-8 bytes.
|
|
41
|
+
# For BMP characters (U+0000..U+FFFF), the code unit equals the code point.
|
|
42
|
+
# For characters above U+FFFF, JavaScript uses surrogate pairs (two 16-bit
|
|
43
|
+
# code units). We replicate this by encoding to UTF-16LE and reading pairs.
|
|
44
|
+
utf16_bytes = str.encode("UTF-16LE").bytes
|
|
45
|
+
i = 0
|
|
46
|
+
while i < utf16_bytes.length
|
|
47
|
+
# Read a 16-bit little-endian code unit (matches JS charCodeAt)
|
|
48
|
+
code_unit = utf16_bytes[i] | (utf16_bytes[i + 1] << 8)
|
|
49
|
+
# hash * 33 + code_unit, then truncate to signed 32-bit via `| 0`
|
|
50
|
+
hash = (((hash << 5) + hash) + code_unit) & 0xFFFFFFFF
|
|
51
|
+
hash = to_signed32(hash)
|
|
52
|
+
i += 2
|
|
53
|
+
end
|
|
54
|
+
# Emulate JavaScript `>>> 0` (convert to unsigned 32-bit)
|
|
55
|
+
unsigned = hash & 0xFFFFFFFF
|
|
56
|
+
unsigned.to_s(16).rjust(8, "0")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# -------------------------------------------------------------------------
|
|
60
|
+
# Internal helpers
|
|
61
|
+
# -------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
# Strip volatile substrings from an error message so that trivially-different
|
|
64
|
+
# occurrences of the same bug hash identically.
|
|
65
|
+
def normalise_message(msg)
|
|
66
|
+
result = msg.dup
|
|
67
|
+
# UUIDs (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
|
68
|
+
result.gsub!(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, "<UUID>")
|
|
69
|
+
# Hex strings (8+ hex chars in a row, word-bounded)
|
|
70
|
+
result.gsub!(/\b[0-9a-f]{8,}\b/i, "<HEX>")
|
|
71
|
+
# Numbers longer than 3 digits
|
|
72
|
+
result.gsub!(/\b\d{4,}\b/, "<NUM>")
|
|
73
|
+
# ISO timestamps
|
|
74
|
+
result.gsub!(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[.\d]*Z?/, "<TIMESTAMP>")
|
|
75
|
+
# Hashed file paths
|
|
76
|
+
result.gsub!(%r{([/\\])[a-zA-Z0-9_-]+[-.]([a-f0-9]{6,})\.(js|ts|mjs|cjs|jsx|tsx)}, '\1<FILE>.\3')
|
|
77
|
+
result.strip
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Extract the top N stack frames as normalised "file:function" strings.
|
|
81
|
+
# Supports V8, SpiderMonkey, and Ruby stack trace formats.
|
|
82
|
+
def extract_top_frames(stack, count)
|
|
83
|
+
return [] if stack.nil? || stack.empty?
|
|
84
|
+
|
|
85
|
+
frames = []
|
|
86
|
+
stack.split("\n").each do |line|
|
|
87
|
+
break if frames.size >= count
|
|
88
|
+
|
|
89
|
+
trimmed = line.strip
|
|
90
|
+
|
|
91
|
+
# V8 format: " at FunctionName (file:line:col)"
|
|
92
|
+
# or " at file:line:col"
|
|
93
|
+
if (v8_match = trimmed.match(/at\s+(?:(.+?)\s+\()?(?:(.+?):\d+:\d+)\)?/))
|
|
94
|
+
fn = v8_match[1] || "<anonymous>"
|
|
95
|
+
file = normalise_path(v8_match[2] || "<unknown>")
|
|
96
|
+
frames << "#{file}:#{fn}"
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# SpiderMonkey / JavaScriptCore: "functionName@file:line:col"
|
|
101
|
+
if (sm_match = trimmed.match(/^(.+?)@(.+?):\d+:\d+/))
|
|
102
|
+
fn = sm_match[1] || "<anonymous>"
|
|
103
|
+
file = normalise_path(sm_match[2] || "<unknown>")
|
|
104
|
+
frames << "#{file}:#{fn}"
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Ruby format: "/path/to/file.rb:42:in `method_name'"
|
|
109
|
+
if (rb_match = trimmed.match(%r{(.+?):(\d+):in\s+[`'](.+?)'}))
|
|
110
|
+
file = normalise_path(rb_match[1])
|
|
111
|
+
fn = rb_match[3]
|
|
112
|
+
frames << "#{file}:#{fn}"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
frames
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Normalise a file path by stripping query strings / hashes and collapsing
|
|
121
|
+
# absolute filesystem prefixes.
|
|
122
|
+
def normalise_path(path)
|
|
123
|
+
result = path.dup
|
|
124
|
+
# Strip query / hash
|
|
125
|
+
result.sub!(/[?#].*$/, "")
|
|
126
|
+
# Collapse node_modules deep paths
|
|
127
|
+
result.sub!(/^.*\/node_modules\//, "node_modules/")
|
|
128
|
+
# Strip origin in URLs
|
|
129
|
+
result.sub!(%r{^https?://[^/]+}, "")
|
|
130
|
+
# Keep only filename
|
|
131
|
+
result.sub!(%r{^.*[/\\]}, "")
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Convert unsigned 32-bit integer to signed 32-bit integer
|
|
136
|
+
# (emulating JavaScript's `| 0` operator).
|
|
137
|
+
def to_signed32(val)
|
|
138
|
+
val = val & 0xFFFFFFFF
|
|
139
|
+
val >= 0x80000000 ? val - 0x100000000 : val
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private_class_method :normalise_message, :extract_top_frames, :normalise_path, :to_signed32
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uncaught
|
|
4
|
+
if defined?(::Rails::Railtie)
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "uncaught.configure" do |app|
|
|
7
|
+
app.middleware.use Uncaught::Middleware
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
config.after_initialize do
|
|
11
|
+
Uncaught.configure do |c|
|
|
12
|
+
c.environment = Rails.env
|
|
13
|
+
c.framework = "Rails"
|
|
14
|
+
c.framework_version = Rails::VERSION::STRING
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rack middleware for Rails / Rack applications.
|
|
21
|
+
#
|
|
22
|
+
# - Adds an HTTP breadcrumb for every request.
|
|
23
|
+
# - Captures unhandled exceptions and re-raises them.
|
|
24
|
+
class Middleware
|
|
25
|
+
def initialize(app)
|
|
26
|
+
@app = app
|
|
27
|
+
@client = Uncaught.client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
# Refresh client reference in case it was reconfigured.
|
|
32
|
+
@client = Uncaught.client if @client.nil?
|
|
33
|
+
|
|
34
|
+
if @client
|
|
35
|
+
@client.add_breadcrumb(
|
|
36
|
+
type: "api_call",
|
|
37
|
+
category: "http",
|
|
38
|
+
message: "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@app.call(env)
|
|
43
|
+
rescue => e
|
|
44
|
+
if @client
|
|
45
|
+
request_info = RequestInfo.new(
|
|
46
|
+
method: env["REQUEST_METHOD"],
|
|
47
|
+
url: env["REQUEST_URI"] || env["PATH_INFO"]
|
|
48
|
+
)
|
|
49
|
+
@client.capture_error(e, request: request_info)
|
|
50
|
+
end
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uncaught
|
|
4
|
+
# Sinatra extension for Uncaught error monitoring.
|
|
5
|
+
#
|
|
6
|
+
# Usage in a Sinatra app:
|
|
7
|
+
#
|
|
8
|
+
# require "uncaught"
|
|
9
|
+
# require "uncaught/integrations/sinatra"
|
|
10
|
+
#
|
|
11
|
+
# class MyApp < Sinatra::Base
|
|
12
|
+
# register Uncaught::Sinatra
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Or in a modular app:
|
|
16
|
+
#
|
|
17
|
+
# Sinatra::Application.register Uncaught::Sinatra
|
|
18
|
+
#
|
|
19
|
+
module SinatraIntegration
|
|
20
|
+
def self.registered(app)
|
|
21
|
+
# Configure Uncaught for Sinatra
|
|
22
|
+
Uncaught.configure do |c|
|
|
23
|
+
c.framework = "Sinatra"
|
|
24
|
+
c.framework_version = ::Sinatra::VERSION if defined?(::Sinatra::VERSION)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add before filter for breadcrumbs
|
|
28
|
+
app.before do
|
|
29
|
+
client = Uncaught.client
|
|
30
|
+
if client
|
|
31
|
+
client.add_breadcrumb(
|
|
32
|
+
type: "api_call",
|
|
33
|
+
category: "http",
|
|
34
|
+
message: "#{request.request_method} #{request.path_info}"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Add error handler
|
|
40
|
+
app.error do
|
|
41
|
+
client = Uncaught.client
|
|
42
|
+
error = env["sinatra.error"]
|
|
43
|
+
|
|
44
|
+
if client && error
|
|
45
|
+
request_info = Uncaught::RequestInfo.new(
|
|
46
|
+
method: request.request_method,
|
|
47
|
+
url: request.url
|
|
48
|
+
)
|
|
49
|
+
client.capture_error(error, request: request_info)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Alias for convenient registration: `register Uncaught::Sinatra`
|
|
56
|
+
Sinatra = SinatraIntegration
|
|
57
|
+
end
|