bug_courier 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: 44f75f93d09c399d9d3ed3f90cd8157c0da39d350163d7daa07fb1d10c183653
4
+ data.tar.gz: 828ced2624eca23b166bf697004b0fd571f74babed1a1fdb2aacff73f0f9237b
5
+ SHA512:
6
+ metadata.gz: 6093f3699a16cf364746c1a0a80060d009ead749bf62476e1f46e419029b177e17c0f62caaa32847cb289b2d64ddd3e5ec6d8fe24f3b9262fbf606681ff7cb1f
7
+ data.tar.gz: b93af4116a9c047e955b476148989ccac2c2cc09b6eee643ff8c5245653208c44aa7ecb6b28f9f4db8513caf9d6c0b0c00275e7acd4091b539aea55112d7ae78
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Steffen Hansen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # BugCourier
2
+
3
+ A Rails gem that automatically creates GitHub issues when uncaught exceptions occur. Includes deduplication (comments on existing open issues instead of creating duplicates), rate limiting, request context capture, and async reporting.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "bug_courier"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ rails generate bug_courier:install
18
+ ```
19
+
20
+ This creates an initializer at `config/initializers/bug_courier.rb`.
21
+
22
+ ## Configuration
23
+
24
+ ```ruby
25
+ BugCourier.configure do |config|
26
+ # Required: GitHub personal access token with 'repo' scope
27
+ config.access_token = ENV["BUG_COURIER_GITHUB_TOKEN"]
28
+
29
+ # Required: GitHub repository in "owner/repo" format
30
+ config.repo = ENV["BUG_COURIER_GITHUB_REPO"]
31
+
32
+ # Labels applied to created issues (default: ["bug", "bug_courier"])
33
+ config.labels = ["bug", "bug_courier"]
34
+
35
+ # GitHub usernames to assign (default: [])
36
+ config.assignees = ["your-github-username"]
37
+
38
+ # Enable/disable (default: true)
39
+ config.enabled = Rails.env.production?
40
+
41
+ # Deduplicate — comments on existing open issues instead of creating new ones (default: true)
42
+ config.deduplicate = true
43
+
44
+ # Max issues created per hour (default: 10)
45
+ config.rate_limit = 10
46
+
47
+ # Optional callback after issue creation/commenting
48
+ config.callback = ->(action, issue) {
49
+ Rails.logger.info("[BugCourier] #{action}: #{issue['html_url']}")
50
+ }
51
+ end
52
+ ```
53
+
54
+ ### GitHub Token
55
+
56
+ Create a [GitHub personal access token](https://github.com/settings/tokens) with the `repo` scope (or `public_repo` for public repositories). Set it as the `BUG_COURIER_GITHUB_TOKEN` environment variable.
57
+
58
+ ## How It Works
59
+
60
+ 1. BugCourier inserts a Rack middleware at the top of your middleware stack
61
+ 2. When an uncaught exception propagates up, the middleware catches it, reports it asynchronously, then re-raises it so normal error handling continues
62
+ 3. If deduplication is enabled, it searches for an existing open issue with the same title and adds a comment instead of creating a duplicate
63
+ 4. Rate limiting prevents flooding your repository with issues during error spikes
64
+
65
+ ### What Gets Reported
66
+
67
+ Each issue includes:
68
+
69
+ - **Exception class and message**
70
+ - **Full backtrace** (first 30 lines)
71
+ - **Request details** — method, URL, IP, user agent, filtered params
72
+ - **Fingerprint** — SHA256 hash for identifying duplicate errors
73
+
74
+ ## License
75
+
76
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugCourier
4
+ class Configuration
5
+ attr_accessor :access_token, :repo, :labels, :assignees, :enabled,
6
+ :deduplicate, :rate_limit, :callback
7
+
8
+ def initialize
9
+ @access_token = nil
10
+ @repo = nil
11
+ @labels = ["bug", "bug_courier"]
12
+ @assignees = []
13
+ @enabled = true
14
+ @deduplicate = true
15
+ @rate_limit = 10 # max issues per hour
16
+ @callback = nil
17
+ end
18
+
19
+ def valid?
20
+ !!(access_token && !access_token.empty? && repo && !repo.empty?)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module BugCourier
6
+ class RateLimiter
7
+ def initialize(max_per_hour:)
8
+ @max_per_hour = max_per_hour
9
+ @timestamps = []
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def allow?
14
+ @mutex.synchronize do
15
+ now = Time.now
16
+ @timestamps.reject! { |t| now - t > 3600 }
17
+ return false if @timestamps.size >= @max_per_hour
18
+
19
+ @timestamps << now
20
+ true
21
+ end
22
+ end
23
+ end
24
+
25
+ class ExceptionHandler
26
+ def initialize
27
+ @rate_limiter = RateLimiter.new(max_per_hour: BugCourier.configuration.rate_limit)
28
+ end
29
+
30
+ def handle(exception, env = {})
31
+ return unless BugCourier.configuration.enabled
32
+ return unless BugCourier.configuration.valid?
33
+ return unless @rate_limiter.allow?
34
+
35
+ title = build_title(exception)
36
+ body = build_body(exception, env)
37
+
38
+ Thread.new do
39
+ report(title, body)
40
+ rescue StandardError => e
41
+ BugCourier.logger&.error("[BugCourier] Async reporting failed: #{e.message}")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def report(title, body)
48
+ config = BugCourier.configuration
49
+ client = GithubClient.new(access_token: config.access_token, repo: config.repo)
50
+
51
+ if config.deduplicate
52
+ existing = client.find_open_issue(title: title)
53
+ if existing
54
+ client.add_comment(
55
+ issue_number: existing["number"],
56
+ body: "**This error occurred again at #{Time.now.utc.iso8601}**\n\n#{body}"
57
+ )
58
+ config.callback&.call(:comment, existing)
59
+ return
60
+ end
61
+ end
62
+
63
+ result = client.create_issue(
64
+ title: title,
65
+ body: body,
66
+ labels: config.labels,
67
+ assignees: config.assignees
68
+ )
69
+
70
+ config.callback&.call(:created, result) if result
71
+ end
72
+
73
+ def build_title(exception)
74
+ location = exception.backtrace&.first&.gsub(Dir.pwd, ".")&.slice(0, 80) || "unknown"
75
+ "[BugCourier] #{exception.class}: #{exception.message.slice(0, 100)} (#{location})"
76
+ end
77
+
78
+ def build_body(exception, env)
79
+ request_info = extract_request_info(env)
80
+ fingerprint = Digest::SHA256.hexdigest("#{exception.class}#{exception.backtrace&.first}")[0, 12]
81
+
82
+ parts = []
83
+ parts << "## #{exception.class}"
84
+ parts << ""
85
+ parts << "**Message:** #{exception.message}"
86
+ parts << ""
87
+ parts << "**Fingerprint:** `#{fingerprint}`"
88
+ parts << ""
89
+
90
+ if request_info.any?
91
+ parts << "## Request Details"
92
+ parts << ""
93
+ request_info.each { |k, v| parts << "- **#{k}:** `#{v}`" }
94
+ parts << ""
95
+ end
96
+
97
+ parts << "## Backtrace"
98
+ parts << ""
99
+ parts << "```"
100
+ backtrace = exception.backtrace || ["No backtrace available"]
101
+ parts << backtrace.first(30).join("\n")
102
+ parts << "```"
103
+ parts << ""
104
+ parts << "---"
105
+ parts << "*Reported by [BugCourier](https://github.com/sgnh/bug-courier) at #{Time.now.utc.iso8601}*"
106
+
107
+ parts.join("\n")
108
+ end
109
+
110
+ def extract_request_info(env)
111
+ return {} if env.nil? || env.empty?
112
+
113
+ info = {}
114
+
115
+ if env.is_a?(Hash)
116
+ request = env["action_dispatch.request"] || (defined?(ActionDispatch::Request) && ActionDispatch::Request.new(env))
117
+
118
+ if request
119
+ info["Method"] = request.request_method rescue nil
120
+ info["URL"] = request.original_url rescue nil
121
+ info["IP"] = request.remote_ip rescue nil
122
+ info["User-Agent"] = request.user_agent rescue nil
123
+ info["Params"] = filtered_params(request) rescue nil
124
+ else
125
+ info["Method"] = env["REQUEST_METHOD"] if env["REQUEST_METHOD"]
126
+ info["Path"] = env["PATH_INFO"] if env["PATH_INFO"]
127
+ info["IP"] = env["REMOTE_ADDR"] if env["REMOTE_ADDR"]
128
+ end
129
+ end
130
+
131
+ info.compact
132
+ end
133
+
134
+ def filtered_params(request)
135
+ params = request.filtered_parameters rescue request.params rescue nil
136
+ return nil if params.nil? || params.empty?
137
+
138
+ params.except("controller", "action").to_s.slice(0, 500)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module BugCourier
8
+ class GithubClient
9
+ GITHUB_API_BASE = "https://api.github.com"
10
+
11
+ def initialize(access_token:, repo:)
12
+ @access_token = access_token
13
+ @repo = repo
14
+ end
15
+
16
+ def create_issue(title:, body:, labels: [], assignees: [])
17
+ uri = URI("#{GITHUB_API_BASE}/repos/#{@repo}/issues")
18
+
19
+ payload = {
20
+ title: title,
21
+ body: body,
22
+ labels: labels,
23
+ assignees: assignees
24
+ }.compact
25
+
26
+ response = post(uri, payload)
27
+
28
+ unless response.is_a?(Net::HTTPCreated)
29
+ BugCourier.logger&.error("[BugCourier] Failed to create GitHub issue: #{response.code} #{response.body}")
30
+ return nil
31
+ end
32
+
33
+ JSON.parse(response.body)
34
+ end
35
+
36
+ def find_open_issue(title:)
37
+ query = "repo:#{@repo} is:issue is:open in:title #{title}"
38
+ uri = URI("#{GITHUB_API_BASE}/search/issues")
39
+ uri.query = URI.encode_www_form(q: query, per_page: 1)
40
+
41
+ response = get(uri)
42
+
43
+ unless response.is_a?(Net::HTTPOK)
44
+ BugCourier.logger&.error("[BugCourier] Failed to search GitHub issues: #{response.code} #{response.body}")
45
+ return nil
46
+ end
47
+
48
+ data = JSON.parse(response.body)
49
+ items = data["items"] || []
50
+ items.find { |issue| issue["title"] == title }
51
+ end
52
+
53
+ def add_comment(issue_number:, body:)
54
+ uri = URI("#{GITHUB_API_BASE}/repos/#{@repo}/issues/#{issue_number}/comments")
55
+
56
+ response = post(uri, { body: body })
57
+
58
+ unless response.is_a?(Net::HTTPCreated)
59
+ BugCourier.logger&.error("[BugCourier] Failed to add comment to issue ##{issue_number}: #{response.code} #{response.body}")
60
+ return nil
61
+ end
62
+
63
+ JSON.parse(response.body)
64
+ end
65
+
66
+ private
67
+
68
+ def post(uri, payload)
69
+ request = Net::HTTP::Post.new(uri)
70
+ request["Authorization"] = "Bearer #{@access_token}"
71
+ request["Accept"] = "application/vnd.github+json"
72
+ request["X-GitHub-Api-Version"] = "2022-11-28"
73
+ request["Content-Type"] = "application/json"
74
+ request.body = JSON.generate(payload)
75
+
76
+ execute(uri, request)
77
+ end
78
+
79
+ def get(uri)
80
+ request = Net::HTTP::Get.new(uri)
81
+ request["Authorization"] = "Bearer #{@access_token}"
82
+ request["Accept"] = "application/vnd.github+json"
83
+ request["X-GitHub-Api-Version"] = "2022-11-28"
84
+
85
+ execute(uri, request)
86
+ end
87
+
88
+ def execute(uri, request)
89
+ http = Net::HTTP.new(uri.host, uri.port)
90
+ http.use_ssl = true
91
+ http.open_timeout = 10
92
+ http.read_timeout = 10
93
+ http.request(request)
94
+ rescue StandardError => e
95
+ BugCourier.logger&.error("[BugCourier] HTTP request failed: #{e.message}")
96
+ nil
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugCourier
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ @handler = ExceptionHandler.new
8
+ end
9
+
10
+ def call(env)
11
+ @app.call(env)
12
+ rescue Exception => exception # rubocop:disable Lint/RescueException
13
+ @handler.handle(exception, env)
14
+ raise
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module BugCourier
6
+ class Railtie < Rails::Railtie
7
+ initializer "bug_courier.configure_middleware" do |app|
8
+ app.middleware.insert_before(0, BugCourier::Middleware)
9
+ end
10
+
11
+ initializer "bug_courier.set_logger" do
12
+ BugCourier.logger = Rails.logger
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugCourier
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bug_courier/version"
4
+ require_relative "bug_courier/configuration"
5
+ require_relative "bug_courier/github_client"
6
+ require_relative "bug_courier/exception_handler"
7
+ require_relative "bug_courier/middleware"
8
+
9
+ module BugCourier
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ attr_accessor :logger
14
+
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield(configuration)
21
+ end
22
+
23
+ def reset!
24
+ @configuration = Configuration.new
25
+ end
26
+ end
27
+ end
28
+
29
+ require_relative "bug_courier/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module BugCourier
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ desc "Creates a BugCourier initializer file at config/initializers/bug_courier.rb"
9
+
10
+ def create_initializer_file
11
+ create_file "config/initializers/bug_courier.rb", <<~RUBY
12
+ # frozen_string_literal: true
13
+
14
+ BugCourier.configure do |config|
15
+ # Required: GitHub personal access token with 'repo' scope
16
+ config.access_token = ENV["BUG_COURIER_GITHUB_TOKEN"]
17
+
18
+ # Required: GitHub repository in "owner/repo" format
19
+ config.repo = ENV["BUG_COURIER_GITHUB_REPO"]
20
+
21
+ # Labels to apply to created issues (default: ["bug", "bug_courier"])
22
+ # config.labels = ["bug", "bug_courier"]
23
+
24
+ # GitHub usernames to assign to created issues (default: [])
25
+ # config.assignees = ["your-github-username"]
26
+
27
+ # Enable/disable BugCourier (default: true)
28
+ # config.enabled = Rails.env.production?
29
+
30
+ # Deduplicate issues — adds comments to existing open issues instead
31
+ # of creating new ones (default: true)
32
+ # config.deduplicate = true
33
+
34
+ # Maximum number of issues to create per hour (default: 10)
35
+ # config.rate_limit = 10
36
+
37
+ # Optional callback — called after issue creation or commenting
38
+ # config.callback = ->(action, issue) {
39
+ # Rails.logger.info("[BugCourier] \#{action}: \#{issue['html_url']}")
40
+ # }
41
+ end
42
+ RUBY
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,4 @@
1
+ module BugCourier
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bug_courier
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Steffen Hansen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 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: net-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: A Rails gem that catches uncaught exceptions and automatically creates
41
+ GitHub issues with full error details, backtraces, and request context. Includes
42
+ deduplication to avoid flooding your repo with duplicate issues.
43
+ email:
44
+ - sgnh@users.noreply.github.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE.txt
50
+ - README.md
51
+ - Rakefile
52
+ - lib/bug_courier.rb
53
+ - lib/bug_courier/configuration.rb
54
+ - lib/bug_courier/exception_handler.rb
55
+ - lib/bug_courier/github_client.rb
56
+ - lib/bug_courier/middleware.rb
57
+ - lib/bug_courier/railtie.rb
58
+ - lib/bug_courier/version.rb
59
+ - lib/generators/bug_courier/install_generator.rb
60
+ - sig/bug_courier.rbs
61
+ homepage: https://github.com/sgnh/bug-courier
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/sgnh/bug-courier
66
+ source_code_uri: https://github.com/sgnh/bug-courier
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 3.2.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.7
82
+ specification_version: 4
83
+ summary: Automatically create GitHub issues from uncaught Rails exceptions.
84
+ test_files: []