bugwatch-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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d791f28771211d583f75d081bab1ac2a669f8cd5e023f8ec4a54045588955781
4
+ data.tar.gz: 5194c786bf49b7549fdf52c3b32fa3a8b2e8b285000f7339828e49bf2f6972d9
5
+ SHA512:
6
+ metadata.gz: e7100cf66b34e6fbf8851d36851e9b9aab6376b8a9eebfd9d08a3f701144a23f056bf17b972e61ca4af00430d1e26c57a24a4fd2b2811530b07ae83ba6e19c01
7
+ data.tar.gz: 915b85cd3c4ab144027c9ab7d6c8098e38dfbf52429adb6c27070045eca8dd9889e82c71613c82653c0ee679182a8c4105421aaf90866c3e5d0babe890ce011c
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # bugwatch-ruby
2
+
3
+ Official Ruby/Rails SDK for [BugWatch](https://bugwatch.io) — open-source error monitoring.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem "bugwatch-ruby"
10
+ ```
11
+
12
+ ```bash
13
+ bundle install
14
+ ```
15
+
16
+ ## Configuration
17
+
18
+ ```ruby
19
+ # config/initializers/bugwatch.rb
20
+ Bugwatch.configure do |c|
21
+ c.api_key = ENV["BUGWATCH_API_KEY"]
22
+ c.endpoint = ENV["BUGWATCH_ENDPOINT"] # e.g. "https://your-app.herokuapp.com"
23
+ c.release_stage = Rails.env.to_s
24
+ c.notify_release_stages = ["production", "staging"]
25
+ c.ignore_classes = ["ActionController::RoutingError"]
26
+ c.app_version = ENV["GIT_REV"] # optional git SHA
27
+ end
28
+ ```
29
+
30
+ ## User context
31
+
32
+ ```ruby
33
+ # app/controllers/application_controller.rb
34
+ before_action :set_bugwatch_user
35
+
36
+ private
37
+
38
+ def set_bugwatch_user
39
+ return unless current_user
40
+ Bugwatch.set_user(
41
+ id: current_user.id,
42
+ email: current_user.email,
43
+ name: current_user.name
44
+ )
45
+ end
46
+ ```
47
+
48
+ ## Breadcrumbs
49
+
50
+ ```ruby
51
+ Bugwatch.leave_breadcrumb("User clicked checkout", type: "ui", metadata: { cart_id: 42 })
52
+ ```
53
+
54
+ ## Manual notification
55
+
56
+ ```ruby
57
+ begin
58
+ risky_operation
59
+ rescue => e
60
+ Bugwatch.notify(e)
61
+ raise
62
+ end
63
+ ```
64
+
65
+ ## How it works
66
+
67
+ 1. `Bugwatch::Middleware` wraps your entire Rack stack.
68
+ 2. When an unhandled exception propagates out of a request, the middleware:
69
+ - Captures the exception, full backtrace (with 3 lines of source context per in-app frame), request details, user context, and breadcrumbs.
70
+ - POSTs the payload to your BugWatch instance in a background thread (fire-and-forget, 3s timeout).
71
+ - Re-raises the exception so Rails error handling fires normally.
72
+ 3. Thread-local user context and breadcrumbs are cleared automatically after each request by the Rails around_action hook.
73
+
74
+ ## What gets sent
75
+
76
+ | Field | Description |
77
+ |-------|-------------|
78
+ | `exception.error_class` | Exception class name |
79
+ | `exception.message` | Exception message |
80
+ | `exception.backtrace` | Array of frames with `file`, `line`, `method`, `in_app`, `source_context` |
81
+ | `request.*` | Method, URL, params (filtered), headers (filtered), IP |
82
+ | `app.*` | Rails env, Ruby version, Rails version, git SHA, hostname |
83
+ | `user.*` | ID, email, name, any custom fields |
84
+ | `breadcrumbs` | Last 50 breadcrumbs with timestamp, type, message, metadata |
85
+ | `duration_ms` | Request duration in milliseconds |
86
+
87
+ Sensitive params (`password`, `token`, `secret`, `key`, `auth`, `credit`, `card`, `cvv`, `ssn`) are automatically filtered from request params and never sent.
88
+
89
+ ## Publishing
90
+
91
+ ```bash
92
+ cd /home/max/bugwatch-ruby
93
+ gem build bugwatch-ruby.gemspec
94
+ gem signin
95
+ gem push bugwatch-ruby-0.1.0.gem
96
+ ```
@@ -0,0 +1,63 @@
1
+ module Bugwatch
2
+ class BacktraceCleaner
3
+ STDLIB_PATHS = [
4
+ RbConfig::CONFIG["rubylibdir"],
5
+ RbConfig::CONFIG["rubyarchdir"],
6
+ Gem.default_dir
7
+ ].compact.map { |p| Regexp.escape(p) }
8
+
9
+ GEM_PATH_RE = /\/gems\//
10
+ STDLIB_RE = Regexp.union(*STDLIB_PATHS) unless STDLIB_PATHS.empty?
11
+
12
+ def self.clean(backtrace)
13
+ return [] unless backtrace
14
+
15
+ backtrace.map do |line|
16
+ path, lineno, label = parse_frame(line)
17
+ in_app = in_app?(path)
18
+
19
+ frame = {
20
+ file: path,
21
+ line: lineno,
22
+ method: label,
23
+ in_app: in_app
24
+ }
25
+
26
+ if in_app && path && File.exist?(path)
27
+ frame[:source_context] = read_source_context(path, lineno)
28
+ end
29
+
30
+ frame
31
+ end
32
+ end
33
+
34
+ def self.parse_frame(line)
35
+ if line =~ /\A(.+?):(\d+):in `(.*)'\z/
36
+ [$1, $2.to_i, $3]
37
+ elsif line =~ /\A(.+?):(\d+)\z/
38
+ [$1, $2.to_i, nil]
39
+ else
40
+ [line, 0, nil]
41
+ end
42
+ end
43
+
44
+ def self.in_app?(path)
45
+ return false if path.nil?
46
+ return false if path.match?(GEM_PATH_RE)
47
+ return false if defined?(STDLIB_RE) && path.match?(STDLIB_RE)
48
+ true
49
+ end
50
+
51
+ def self.read_source_context(path, lineno, context_lines: 3)
52
+ lines = File.readlines(path)
53
+ first = [lineno - context_lines - 1, 0].max
54
+ last = [lineno + context_lines - 1, lines.size - 1].min
55
+
56
+ lines[first..last].each_with_object({}).with_index(first + 1) do |(source_line, hash), n|
57
+ hash[n] = source_line.chomp
58
+ end
59
+ rescue Errno::ENOENT, Errno::EACCES
60
+ {}
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,25 @@
1
+ module Bugwatch
2
+ module BreadcrumbCollector
3
+ THREAD_KEY = :bugwatch_breadcrumbs
4
+ MAX_CRUMBS = 50
5
+
6
+ def self.add(message, type: "manual", metadata: {})
7
+ crumbs = Thread.current[THREAD_KEY] ||= []
8
+ crumbs << {
9
+ timestamp: Time.now.utc.iso8601(3),
10
+ type: type,
11
+ message: message.to_s,
12
+ metadata: metadata
13
+ }
14
+ crumbs.shift if crumbs.size > MAX_CRUMBS
15
+ end
16
+
17
+ def self.all
18
+ Thread.current[THREAD_KEY] || []
19
+ end
20
+
21
+ def self.clear
22
+ Thread.current[THREAD_KEY] = []
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ module Bugwatch
2
+ class Configuration
3
+ attr_accessor :api_key,
4
+ :endpoint,
5
+ :release_stage,
6
+ :notify_release_stages,
7
+ :ignore_classes,
8
+ :app_version,
9
+ :logger
10
+
11
+ def initialize
12
+ @endpoint = nil
13
+ @release_stage = "production"
14
+ @notify_release_stages = ["production"]
15
+ @ignore_classes = []
16
+ @logger = Logger.new($stdout) if defined?(Logger)
17
+ end
18
+
19
+ def notify_for_release_stage?
20
+ notify_release_stages.include?(release_stage.to_s)
21
+ end
22
+
23
+ def ignore?(exception)
24
+ ignore_classes.any? do |klass|
25
+ klass = Object.const_get(klass) if klass.is_a?(String)
26
+ exception.is_a?(klass)
27
+ rescue NameError
28
+ false
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,75 @@
1
+ module Bugwatch
2
+ class ErrorBuilder
3
+ def initialize(exception, rack_env = nil, config: Bugwatch.configuration)
4
+ @exception = exception
5
+ @rack_env = rack_env
6
+ @config = config
7
+ end
8
+
9
+ def build
10
+ {
11
+ exception: exception_payload,
12
+ request: request_payload,
13
+ app: app_payload,
14
+ user: UserContext.get,
15
+ breadcrumbs: BreadcrumbCollector.all
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ def exception_payload
22
+ {
23
+ error_class: @exception.class.name,
24
+ message: @exception.message,
25
+ backtrace: BacktraceCleaner.clean(@exception.backtrace)
26
+ }
27
+ end
28
+
29
+ def request_payload
30
+ return {} unless @rack_env
31
+
32
+ req = Rack::Request.new(@rack_env)
33
+
34
+ {
35
+ method: req.request_method,
36
+ url: req.url,
37
+ params: safe_params(req),
38
+ headers: extract_headers(@rack_env),
39
+ ip: req.ip
40
+ }
41
+ rescue StandardError
42
+ {}
43
+ end
44
+
45
+ def app_payload
46
+ payload = {
47
+ environment: @config.release_stage,
48
+ version: @config.app_version,
49
+ hostname: Socket.gethostname
50
+ }
51
+
52
+ if defined?(RUBY_VERSION)
53
+ payload[:ruby_version] = RUBY_VERSION
54
+ end
55
+
56
+ if defined?(Rails)
57
+ payload[:rails_version] = Rails.version
58
+ end
59
+
60
+ payload
61
+ end
62
+
63
+ def safe_params(req)
64
+ req.params.reject { |k, _| k.to_s.downcase.match?(/password|secret|token|key|auth|credit|card|cvv|ssn/) }
65
+ rescue StandardError
66
+ {}
67
+ end
68
+
69
+ def extract_headers(env)
70
+ env.select { |k, _| k.start_with?("HTTP_") }
71
+ .transform_keys { |k| k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-") }
72
+ .reject { |k, _| k.downcase.match?(/cookie|authorization/) }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,28 @@
1
+ module Bugwatch
2
+ class Middleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
+ BreadcrumbCollector.clear
10
+ UserContext.clear
11
+
12
+ begin
13
+ status, headers, body = @app.call(env)
14
+ [status, headers, body]
15
+ rescue Exception => e # rubocop:disable Lint/RescueException
16
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
17
+
18
+ unless Bugwatch.configuration.ignore?(e)
19
+ payload = ErrorBuilder.new(e, env).build
20
+ payload[:duration_ms] = duration_ms
21
+ Notification.new(payload).deliver
22
+ end
23
+
24
+ raise
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Bugwatch
6
+ class Notification
7
+ TIMEOUT = 3
8
+
9
+ def initialize(payload, config: Bugwatch.configuration)
10
+ @payload = payload
11
+ @config = config
12
+ end
13
+
14
+ def deliver
15
+ return unless @config.api_key
16
+ return unless @config.endpoint
17
+ return unless @config.notify_for_release_stage?
18
+
19
+ Thread.new do
20
+ post_to_api
21
+ rescue StandardError
22
+ # Fire-and-forget: swallow all errors so the gem never affects the app
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def post_to_api
29
+ uri = URI.parse("#{@config.endpoint.chomp("/")}/api/v1/errors")
30
+
31
+ http = Net::HTTP.new(uri.host, uri.port)
32
+ http.use_ssl = uri.scheme == "https"
33
+ http.open_timeout = TIMEOUT
34
+ http.read_timeout = TIMEOUT
35
+ http.write_timeout = TIMEOUT
36
+
37
+ request = Net::HTTP::Post.new(uri.path)
38
+ request["Content-Type"] = "application/json"
39
+ request["X-Api-Key"] = @config.api_key
40
+ request["X-BugWatch-Ruby"] = Bugwatch::VERSION
41
+
42
+ request.body = JSON.generate(@payload)
43
+
44
+ http.request(request)
45
+ rescue StandardError
46
+ # Silently discard network errors
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,32 @@
1
+ module Bugwatch
2
+ class Railtie < Rails::Railtie
3
+ initializer "bugwatch.middleware" do |app|
4
+ app.middleware.use Bugwatch::Middleware
5
+ end
6
+
7
+ initializer "bugwatch.action_controller" do
8
+ ActiveSupport.on_load(:action_controller) do
9
+ include Bugwatch::ControllerMethods
10
+ end
11
+ end
12
+ end
13
+
14
+ module ControllerMethods
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ around_action :bugwatch_reset_context
19
+ end
20
+
21
+ private
22
+
23
+ def bugwatch_reset_context
24
+ Bugwatch::UserContext.clear
25
+ Bugwatch::BreadcrumbCollector.clear
26
+ yield
27
+ ensure
28
+ Bugwatch::UserContext.clear
29
+ Bugwatch::BreadcrumbCollector.clear
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ module Bugwatch
2
+ module UserContext
3
+ THREAD_KEY = :bugwatch_user_context
4
+
5
+ def self.set(id: nil, email: nil, name: nil, **custom)
6
+ Thread.current[THREAD_KEY] = { id: id, email: email, name: name }.merge(custom).compact
7
+ end
8
+
9
+ def self.get
10
+ Thread.current[THREAD_KEY] || {}
11
+ end
12
+
13
+ def self.clear
14
+ Thread.current[THREAD_KEY] = nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ module Bugwatch
2
+ VERSION = "0.1.0"
3
+ end
data/lib/bugwatch.rb ADDED
@@ -0,0 +1,41 @@
1
+ require "logger"
2
+ require "socket"
3
+
4
+ require_relative "bugwatch/version"
5
+ require_relative "bugwatch/configuration"
6
+ require_relative "bugwatch/user_context"
7
+ require_relative "bugwatch/breadcrumb_collector"
8
+ require_relative "bugwatch/backtrace_cleaner"
9
+ require_relative "bugwatch/error_builder"
10
+ require_relative "bugwatch/notification"
11
+ require_relative "bugwatch/middleware"
12
+ require_relative "bugwatch/railtie" if defined?(Rails::Railtie)
13
+
14
+ module Bugwatch
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield configuration
22
+ end
23
+
24
+ def notify(exception, context: {})
25
+ return if configuration.ignore?(exception)
26
+ return unless configuration.notify_for_release_stage?
27
+
28
+ payload = ErrorBuilder.new(exception).build
29
+ payload.merge!(context)
30
+ Notification.new(payload).deliver
31
+ end
32
+
33
+ def set_user(id: nil, email: nil, name: nil, **custom)
34
+ UserContext.set(id: id, email: email, name: name, **custom)
35
+ end
36
+
37
+ def leave_breadcrumb(message, type: "manual", metadata: {})
38
+ BreadcrumbCollector.add(message, type: type, metadata: metadata)
39
+ end
40
+ end
41
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bugwatch-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - BugWatch
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-04-05 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Automatically captures and reports exceptions from your Rails app to
41
+ BugWatch, with request context, user context, breadcrumbs, and source code snippets.
42
+ email:
43
+ - support@bugwatch.io
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - lib/bugwatch.rb
50
+ - lib/bugwatch/backtrace_cleaner.rb
51
+ - lib/bugwatch/breadcrumb_collector.rb
52
+ - lib/bugwatch/configuration.rb
53
+ - lib/bugwatch/error_builder.rb
54
+ - lib/bugwatch/middleware.rb
55
+ - lib/bugwatch/notification.rb
56
+ - lib/bugwatch/railtie.rb
57
+ - lib/bugwatch/user_context.rb
58
+ - lib/bugwatch/version.rb
59
+ homepage: https://github.com/bugwatch/bugwatch-ruby
60
+ licenses:
61
+ - MIT
62
+ metadata: {}
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.0'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.2
78
+ specification_version: 4
79
+ summary: Official Ruby/Rails SDK for BugWatch error monitoring
80
+ test_files: []