bugwatch-sdk 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: 94f9c2ad3792e673573fa9fe2d517d4b9eb591fb2f1bacb18d231c41c7c46d9c
4
+ data.tar.gz: b1d6325a16b22c9b4770be9d9e1d49f2df71e1f7ce5462fbe94856f859467dbc
5
+ SHA512:
6
+ metadata.gz: a2aec0365f97d36f3a4abce0ec72097b0f0b5ae5773e803cf950868885c9d5c7eb271555a0ebaf17151128ede1b67f708521ba7cfeddaeea15f6968402d8499e
7
+ data.tar.gz: 7547ca8e2cf31b869463bbdbafd75408a932bd5df955379ee5fe7c247f9ab06e4b7e38a14e87a4ba4cd68c1bf94f3fd9fe8e1d2019430b780f3a66a9f624a422
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ROOMY AI LLC
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Bugwatch Ruby SDK
2
+
3
+ Official Ruby SDK for Bugwatch error tracking. Stdlib only — zero runtime dependencies.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ gem 'bugwatch-sdk', '~> 0.1'
9
+ # or from source:
10
+ gem 'bugwatch-sdk', github: 'roomylabs/bugwatch-ruby'
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ruby
16
+ require "bugwatch"
17
+
18
+ Bugwatch.init(
19
+ "https://bw-YOUR_KEY@bugwatch-api.loadmindx.com/api/22",
20
+ environment: "production",
21
+ release: "1.0.0",
22
+ )
23
+
24
+ # Capture the active exception.
25
+ begin
26
+ 1 / 0
27
+ rescue ZeroDivisionError => e
28
+ Bugwatch.capture_exception(e)
29
+ end
30
+
31
+ # Capture a message.
32
+ Bugwatch.capture_message("Payment confirmed", level: "info")
33
+
34
+ # Wait for async sends (short-lived scripts).
35
+ Bugwatch.flush
36
+ ```
37
+
38
+ ## API
39
+
40
+ | Method | Description |
41
+ |---|---|
42
+ | `init(dsn, environment:, release:, debug:, send_default_pii:, allow_http:)` | Initialize the SDK (HTTPS enforced; `allow_http` is dev-only) |
43
+ | `capture_exception(exc = $!, level:, tags:, user:)` | Report an exception (defaults to `$!` inside rescue) |
44
+ | `capture_message(message, level:, tags:, user:)` | Report a message (`debug info warning error fatal`) |
45
+ | `configure_scope { \|scope\| ... }` | Set global tags / user / breadcrumbs / context |
46
+ | `flush(timeout: 5.0)` | Wait for in-flight sends; returns `true` when done |
47
+
48
+ ## Scope
49
+
50
+ ```ruby
51
+ Bugwatch.configure_scope do |scope|
52
+ scope.set_tag("tenant", "acme")
53
+ scope.set_user("id" => 42, "name" => "Grace") # email/ip stripped (PII)
54
+ scope.add_breadcrumb("clicked checkout", category: "ui")
55
+ end
56
+ ```
57
+
58
+ ## Rack middleware (Rails, Sinatra…)
59
+
60
+ ```ruby
61
+ # config/application.rb (Rails) or `use Bugwatch::RackMiddleware` (Sinatra)
62
+ require "bugwatch"
63
+ config.middleware.insert_before 0, Bugwatch::RackMiddleware
64
+ ```
65
+
66
+ Captures unhandled exceptions with request context (URL, method, headers — sensitive headers stripped).
67
+
68
+ ## Behavior
69
+
70
+ - Sends are **async** (background threads, max 50 in flight); call `flush` in short-lived scripts
71
+ - **HTTPS enforced** — `http://` DSNs raise unless `allow_http: true`
72
+ - **PII stripped by default** — `email`, `username`, `ip_address` removed from user; `Cookie`/`Authorization` headers removed from request context
73
+ - Breadcrumbs capped at 100; failures never crash the host app
74
+ - DSN format: `https://<public_key>@<host>/api/<project_id>` (the project's ingest key — Settings → API keys)
@@ -0,0 +1,84 @@
1
+ require "time"
2
+
3
+ module Bugwatch
4
+ module Client
5
+ MAX_THREADS = 50
6
+
7
+ module_function
8
+
9
+ def payload(event_id:, level:, message:, culprit:, exception:, user:, tags:)
10
+ cfg = Bugwatch.config
11
+ scope = cfg[:scope].snapshot
12
+ p = {
13
+ "event_id" => event_id,
14
+ "timestamp" => Time.now.utc.iso8601,
15
+ "level" => level.to_s,
16
+ "message" => message,
17
+ "culprit" => culprit,
18
+ "environment" => cfg[:environment],
19
+ "release" => cfg[:release],
20
+ "platform" => "ruby",
21
+ }
22
+ p["exception"] = exception if exception
23
+ merged_user = user || scope[:user]
24
+ p["user"] = Dsn.strip_pii_user(merged_user) if merged_user
25
+ merged_tags = scope[:tags].merge(tags || {})
26
+ p["tags"] = merged_tags unless merged_tags.empty?
27
+ p["breadcrumbs"] = scope[:breadcrumbs] unless scope[:breadcrumbs].empty?
28
+ req = scope[:context]["request"]
29
+ if req
30
+ req = cfg[:send_default_pii] ? req : Dsn.strip_pii_request(req)
31
+ p["request"] = req
32
+ end
33
+ p
34
+ end
35
+
36
+ def send(payload)
37
+ cfg = Bugwatch.config
38
+ thread = Thread.new do
39
+ post(cfg[:store_url], cfg[:public_key], payload)
40
+ end
41
+ thread.report_on_exception = false
42
+ pool << thread
43
+ prune_pool
44
+ true
45
+ end
46
+
47
+ def flush(timeout = 5.0)
48
+ deadline = Time.now + timeout
49
+ pool.dup.each { |t| t.join([0.01, deadline - Time.now].max) while Time.now < deadline && !t.join(0.05) }
50
+ pool.dup.each(&:join) if Time.now < deadline
51
+ pool.clear
52
+ true
53
+ end
54
+
55
+ def pool
56
+ @pool ||= []
57
+ end
58
+
59
+ def prune_pool
60
+ pool.reject! { |t| !t.alive? }
61
+ pool.shift while pool.size > MAX_THREADS
62
+ end
63
+
64
+ def post(store_url, public_key, payload)
65
+ uri = URI.parse(store_url)
66
+ http = Net::HTTP.new(uri.host, uri.port)
67
+ http.use_ssl = uri.scheme == "https"
68
+ http.open_timeout = 5
69
+ http.read_timeout = 5
70
+ req = Net::HTTP::Post.new(uri.request_uri)
71
+ req["Content-Type"] = "application/json"
72
+ req["X-Bugwatch-Key"] = public_key
73
+ req["User-Agent"] = USER_AGENT
74
+ req.body = JSON.generate(payload)
75
+ http.request(req)
76
+ rescue StandardError => e
77
+ warn("bugwatch: send failed: #{e.message}") if Bugwatch.config[:debug]
78
+ end
79
+
80
+ def warn(msg)
81
+ $stderr.puts(msg)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,69 @@
1
+ module Bugwatch
2
+ module Dsn
3
+ PII_KEYS = %w[email username ip_address ip].freeze
4
+ SENSITIVE_HEADERS = %w[cookie authorization x-bugwatch-key set-cookie].freeze
5
+
6
+ module_function
7
+
8
+ def parse(dsn, allow_http: false)
9
+ raise Error, "DSN must be a non-empty string" unless dsn.is_a?(String) && !dsn.strip.empty?
10
+ uri = URI.parse(dsn.strip)
11
+ unless %w[http https].include?(uri.scheme)
12
+ raise Error, "Invalid DSN scheme: expected https (got #{uri.scheme.inspect})"
13
+ end
14
+ if uri.scheme == "http" && !allow_http
15
+ raise Error, "HTTP DSN refused. Use https:// (allow_http=True is dev-only)"
16
+ end
17
+ raise Error, "DSN missing public key before '@'" unless uri.userinfo && !uri.userinfo.empty?
18
+ raise Error, "DSN missing host" unless uri.host
19
+ segments = uri.path.split("/").reject(&:empty?)
20
+ project_id =
21
+ if segments.size >= 2 && segments[0].downcase == "api"
22
+ segments[1]
23
+ else
24
+ segments[0]
25
+ end
26
+ raise Error, "DSN missing project_id in path" if project_id.nil? || project_id.empty?
27
+ base = "#{uri.scheme}://#{uri.host}#{uri.port == uri.default_port ? '' : ":#{uri.port}"}"
28
+ { public_key: uri.userinfo, base_url: base, project_id: project_id,
29
+ store_url: "#{base}/api/#{project_id}/store/" }
30
+ rescue URI::InvalidURIError, URI::Error
31
+ raise Error, "Invalid DSN: #{dsn.inspect}"
32
+ end
33
+
34
+ def exception_payload(exc)
35
+ return nil unless exc
36
+ frames = (exc.backtrace || []).first(50).map do |line|
37
+ file, lineno, fn = line.split(":", 3)
38
+ { "filename" => file.to_s, "lineno" => (lineno.to_i rescue 0),
39
+ "function" => (fn ? fn.split(":in ").last.to_s : nil) }.compact
40
+ end
41
+ { "type" => exc.class.name.to_s, "value" => exc.message.to_s,
42
+ "stacktrace" => { "frames" => frames } }
43
+ end
44
+
45
+ def culprit_from(exc)
46
+ first = (exc.backtrace || []).first
47
+ return nil unless first
48
+ file, lineno, = first.split(":", 3)
49
+ "#{File.basename(file.to_s)}:#{lineno}"
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def strip_pii_user(user)
55
+ return nil unless user.is_a?(Hash)
56
+ user.reject { |k, _| PII_KEYS.include?(k.to_s.downcase) }
57
+ end
58
+
59
+ def strip_pii_request(req)
60
+ return req unless req.is_a?(Hash)
61
+ out = req.dup
62
+ headers = out["headers"]
63
+ if headers.is_a?(Hash)
64
+ out["headers"] = headers.reject { |k, _| SENSITIVE_HEADERS.include?(k.to_s.downcase) }
65
+ end
66
+ out
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,28 @@
1
+ module Bugwatch
2
+ # Rack middleware: captures unhandled exceptions from any Rack app
3
+ # (Rails, Sinatra, etc.) and reports them to Bugwatch.
4
+ class RackMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ @app.call(env)
11
+ rescue Exception => e
12
+ begin
13
+ scope = Bugwatch.initialized? ? Bugwatch.config[:scope] : nil
14
+ if scope
15
+ scope.set_context("request" => {
16
+ "url" => "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}#{env['REQUEST_PATH']}",
17
+ "method" => env["REQUEST_METHOD"],
18
+ "headers" => env.select { |k, _| k.to_s.start_with?("HTTP_") },
19
+ })
20
+ end
21
+ Bugwatch.capture_exception(e)
22
+ ensure
23
+ scope&.set_context("request" => nil)
24
+ raise e
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,4 @@
1
+ module Bugwatch
2
+ VERSION = "0.1.0".freeze
3
+ USER_AGENT = "Bugwatch-Ruby/#{VERSION}".freeze
4
+ end
data/lib/bugwatch.rb ADDED
@@ -0,0 +1,121 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "securerandom"
5
+ require "thread"
6
+
7
+ require_relative "bugwatch/version"
8
+ require_relative "bugwatch/dsn"
9
+ require_relative "bugwatch/client"
10
+ require_relative "bugwatch/integration"
11
+
12
+ module Bugwatch
13
+ class Error < StandardError; end
14
+
15
+ LEVELS = %w[debug info warning error fatal].freeze
16
+ DEFAULT_LEVEL = "error"
17
+
18
+ class << self
19
+ def init(dsn, environment: "production", release: nil, debug: false,
20
+ send_default_pii: false, allow_http: false)
21
+ cfg = Dsn.parse(dsn, allow_http: allow_http)
22
+ @config = {
23
+ store_url: cfg[:store_url], public_key: cfg[:public_key],
24
+ project_id: cfg[:project_id], environment: environment.to_s,
25
+ release: release, debug: !!debug, send_default_pii: !!send_default_pii,
26
+ scope: Scope.new
27
+ }
28
+ true
29
+ end
30
+
31
+ def config
32
+ @config or raise Error, "Bugwatch.init must be called before use"
33
+ end
34
+
35
+ def initialized?
36
+ !!@config
37
+ end
38
+
39
+ def capture_exception(exc = $!, level: DEFAULT_LEVEL, tags: nil, user: nil)
40
+ return nil unless initialized?
41
+ exc ||= $ERROR_INFO
42
+ return nil unless exc.is_a?(Exception)
43
+ event_id = SecureRandom.uuid.delete("-")
44
+ payload = Client.payload(
45
+ event_id: event_id, level: level, message: "#{exc.class}: #{exc.message}",
46
+ culprit: Dsn.culprit_from(exc),
47
+ exception: Dsn.exception_payload(exc),
48
+ user: user, tags: tags
49
+ )
50
+ Client.send(payload)
51
+ event_id
52
+ end
53
+
54
+ def capture_message(message, level: "info", tags: nil, user: nil)
55
+ return nil unless initialized?
56
+ event_id = SecureRandom.uuid.delete("-")
57
+ payload = Client.payload(
58
+ event_id: event_id, level: level, message: message.to_s,
59
+ culprit: nil, exception: nil, user: user, tags: tags
60
+ )
61
+ Client.send(payload)
62
+ event_id
63
+ end
64
+
65
+ def configure_scope
66
+ yield config[:scope] if block_given?
67
+ end
68
+
69
+ def flush(timeout: 5.0)
70
+ Client.flush(timeout)
71
+ end
72
+ end
73
+
74
+ class Scope
75
+ attr_reader :tags, :user, :breadcrumbs, :context
76
+
77
+ def initialize
78
+ @tags = {}
79
+ @user = nil
80
+ @breadcrumbs = []
81
+ @context = {}
82
+ @lock = Mutex.new
83
+ end
84
+
85
+ def set_user(user)
86
+ @lock.synchronize { @user = Dsn.strip_pii_user(user) }
87
+ end
88
+
89
+ def set_tag(key, value)
90
+ @lock.synchronize { @tags[key.to_s] = value.to_s }
91
+ end
92
+
93
+ def set_context(context)
94
+ @lock.synchronize { @context.merge!(context) }
95
+ end
96
+
97
+ def add_breadcrumb(message, category: nil, level: "info", data: nil)
98
+ crumb = { "timestamp" => Time.now.utc.iso8601, "message" => message.to_s,
99
+ "level" => level.to_s }
100
+ crumb["category"] = category if category
101
+ crumb["data"] = data if data
102
+ @lock.synchronize do
103
+ @breadcrumbs << crumb
104
+ @breadcrumbs.shift if @breadcrumbs.size > 100
105
+ end
106
+ end
107
+
108
+ def snapshot
109
+ @lock.synchronize do
110
+ { tags: @tags.dup, user: @user && @user.dup,
111
+ breadcrumbs: @breadcrumbs.map(&:dup), context: deep_dup(@context) }
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def deep_dup(h)
118
+ h.each_with_object({}) { |(k, v), acc| acc[k] = v.is_a?(Hash) ? deep_dup(v) : v }
119
+ end
120
+ end
121
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bugwatch-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ROOMY AI LLC
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ description: Capture exceptions and messages from Ruby apps to Bugwatch. Stdlib only.
28
+ email:
29
+ - billing@roomylabs.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - lib/bugwatch.rb
37
+ - lib/bugwatch/client.rb
38
+ - lib/bugwatch/dsn.rb
39
+ - lib/bugwatch/integration.rb
40
+ - lib/bugwatch/version.rb
41
+ homepage: https://bugwatch.roomylabs.com
42
+ licenses:
43
+ - MIT
44
+ metadata: {}
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.4.20
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: Official Ruby SDK for Bugwatch error tracking
64
+ test_files: []