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