tensorbuzz-api 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: e6c56342468d420d2953579a8724215a497335b7d574d2efd99853e88e21aae1
4
+ data.tar.gz: ce6ae673c3562b6fa1119e0ac48f5db086353cbd8268f55ad043581c45133f43
5
+ SHA512:
6
+ metadata.gz: a7d3a46e7cdde24c0257aaf433235260824e6f15db03a5aa784628b877e5a710160c70bf3b0597e0bf7007f481cb8f740e666763ca4fb48e275e08b95974534b
7
+ data.tar.gz: 253e6d374148290d16534380b8878e39fe12846bec9207bf5b3afd5fbf8299ce40729e0501b13a6f4513a874bb5c662cade7d99222fc5990c6bd37c404079389
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kasper Stöckel
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,55 @@
1
+ # tensorbuzz-api for Ruby
2
+
3
+ Ruby, Rack, Rails, and Sidekiq error reporting for TensorBuzz.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "tensorbuzz-api"
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ ```ruby
14
+ require "tensorbuzz/api"
15
+
16
+ TensorBuzz::BugReporting.configure do |config|
17
+ config.auth_token = ENV.fetch("TENSORBUZZ_BUG_REPORT_AUTH_TOKEN")
18
+ config.project_id = ENV.fetch("TENSORBUZZ_BUG_REPORT_PROJECT_ID")
19
+ config.hostname = ENV["TENSORBUZZ_BUG_REPORT_HOSTNAME"]
20
+ config.environment = ENV.fetch("RAILS_ENV", "production")
21
+ config.release = ENV["APP_RELEASE"]
22
+ config.logger = Rails.logger
23
+ end
24
+ ```
25
+
26
+ The default endpoint is `https://server.tensorbuzz.com/errors/reports`. Set
27
+ `config.post_url` when using another TensorBuzz installation.
28
+
29
+ ## Reporting
30
+
31
+ ```ruby
32
+ TensorBuzz::BugReporting.report(error, parameters: {order_id: order.id})
33
+ TensorBuzz::BugReporting.report_message("Payment session creation failed")
34
+
35
+ TensorBuzz::BugReporting.with_parameters(user_id: user.id) do
36
+ perform_work
37
+ end
38
+ ```
39
+
40
+ Scoped parameters are attached to exceptions that leave the block, allowing a
41
+ later Rails or Sidekiq handler to report the original context.
42
+
43
+ ## Rails and Sidekiq
44
+
45
+ ```ruby
46
+ require "tensorbuzz/api/rails"
47
+ require "tensorbuzz/api/sidekiq"
48
+
49
+ TensorBuzz::Api::Rails.configure
50
+ TensorBuzz::Api::Sidekiq.configure
51
+ ```
52
+
53
+ The Rack middleware reports uncaught request errors and re-raises the original
54
+ exception. The Sidekiq integration uses Sidekiq's error-handler API. Reporting
55
+ failures are logged and never replace the application exception.
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'socket'
5
+
6
+ module TensorBuzz
7
+ module Api
8
+ class BugReporting
9
+ CAPTURED_PARAMETERS_VARIABLE = :@tensorbuzz_captured_parameters
10
+ CONTEXT_KEY = :tensorbuzz_bug_reporting_context
11
+
12
+ class << self
13
+ attr_reader :current
14
+
15
+ def configure
16
+ configuration = Configuration.new
17
+ yield configuration
18
+ configuration.validate!
19
+ @current = new(configuration)
20
+ end
21
+
22
+ def report(error, **options)
23
+ current&.report(error, **options)
24
+ end
25
+
26
+ def report_message(message, **options)
27
+ report(RuntimeError.new(message), **options)
28
+ end
29
+
30
+ def safely_report(error, **options)
31
+ report(error, **options)
32
+ rescue StandardError => e
33
+ current&.log_reporting_error(e)
34
+ nil
35
+ end
36
+
37
+ def with_parameters(parameters)
38
+ return yield unless current
39
+
40
+ context_id = SecureRandom.hex(16)
41
+ context[context_id] = parameters
42
+ yield
43
+ rescue Exception => e # rubocop:disable Lint/RescueException
44
+ current&.capture_parameters_for_error(e)
45
+ raise
46
+ ensure
47
+ context.delete(context_id) if context_id
48
+ end
49
+
50
+ def reset!
51
+ @current = nil
52
+ Thread.current[CONTEXT_KEY] = nil
53
+ end
54
+
55
+ def context
56
+ Thread.current[CONTEXT_KEY] ||= {}
57
+ end
58
+ end
59
+
60
+ def initialize(configuration, transport: Transport.new(configuration))
61
+ @configuration = configuration
62
+ @transport = transport
63
+ end
64
+
65
+ def report(error, environment: nil, http_method: nil, parameters: nil, remote_ip: nil,
66
+ request_body: nil, url: nil, user_agent: nil)
67
+ normalized_error = normalize_error(error)
68
+ stack_trace = Array(normalized_error.backtrace)
69
+ file_path, line_number = location_from(stack_trace)
70
+ merged_environment = compact_hash(
71
+ application_environment: configuration.environment,
72
+ release: configuration.release
73
+ ).merge(environment || {})
74
+ payload = {
75
+ authToken: configuration.auth_token,
76
+ projectId: configuration.project_id,
77
+ hostname: configuration.hostname || Socket.gethostname,
78
+ runtimeEnvironment: 'ruby',
79
+ error: {
80
+ backtrace: stack_trace,
81
+ environment: Sanitizer.call(merged_environment),
82
+ error_class: normalized_error.class.name,
83
+ file_path:,
84
+ http_method:,
85
+ line_number:,
86
+ message: normalized_error.message,
87
+ parameters: Sanitizer.call(merged_parameters(normalized_error, parameters)),
88
+ remote_ip:,
89
+ request_body: Sanitizer.call(request_body),
90
+ url:,
91
+ user_agent:
92
+ }.compact
93
+ }
94
+ response = transport.post(payload)
95
+
96
+ Response.new(
97
+ bug_report_id: response['bugReportId'],
98
+ bug_report_instance_id: response['bugReportInstanceId'],
99
+ project_id: response['projectId'],
100
+ project_slug: response['projectSlug'],
101
+ url: response['url']
102
+ )
103
+ end
104
+
105
+ def capture_parameters_for_error(error)
106
+ return if error.instance_variable_defined?(CAPTURED_PARAMETERS_VARIABLE)
107
+
108
+ error.instance_variable_set(CAPTURED_PARAMETERS_VARIABLE, merged_parameters(error, nil))
109
+ end
110
+
111
+ def log_reporting_error(error)
112
+ logger = configuration.logger
113
+ message = "TensorBuzz reporting failed: #{error.class}: #{error.message}"
114
+
115
+ if logger
116
+ logger.error(message)
117
+ else
118
+ warn(message)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ attr_reader :configuration, :transport
125
+
126
+ def compact_hash(**values)
127
+ values.compact
128
+ end
129
+
130
+ def deep_merge(left, right)
131
+ left.merge(right) do |_key, left_value, right_value|
132
+ if left_value.is_a?(Hash) && right_value.is_a?(Hash)
133
+ deep_merge(left_value, right_value)
134
+ else
135
+ right_value
136
+ end
137
+ end
138
+ end
139
+
140
+ def location_from(backtrace)
141
+ backtrace.each do |line|
142
+ match = line.match(/\A(.+?):(\d+)(?::|\z)/)
143
+ return [match[1], match[2].to_i] if match
144
+ end
145
+
146
+ [nil, nil]
147
+ end
148
+
149
+ def merged_parameters(error, parameters)
150
+ values = self.class.context.values
151
+ if error.instance_variable_defined?(CAPTURED_PARAMETERS_VARIABLE)
152
+ captured = error.instance_variable_get(CAPTURED_PARAMETERS_VARIABLE)
153
+ end
154
+ values << captured if captured
155
+ values << parameters if parameters
156
+ values.compact.reduce({}) { |result, value| deep_merge(result, value) }
157
+ end
158
+
159
+ def normalize_error(error)
160
+ normalized_error = error.is_a?(Exception) ? error : RuntimeError.new(error.to_s)
161
+ normalized_error.set_backtrace(caller(2)) if normalized_error.backtrace.nil? || normalized_error.backtrace.empty?
162
+ normalized_error
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TensorBuzz
4
+ module Api
5
+ class Configuration
6
+ attr_accessor :auth_token, :environment, :hostname, :logger, :open_timeout,
7
+ :post_url, :project_id, :read_timeout, :release
8
+
9
+ def initialize
10
+ @open_timeout = 2
11
+ @post_url = 'https://server.tensorbuzz.com/errors/reports'
12
+ @read_timeout = 5
13
+ end
14
+
15
+ def validate!
16
+ raise ArgumentError, 'TensorBuzz auth_token is required' if auth_token.to_s.empty?
17
+ raise ArgumentError, 'TensorBuzz project_id is required' if project_id.to_s.empty?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'rack/request'
5
+ require 'json'
6
+
7
+ module TensorBuzz
8
+ module Api
9
+ class RackMiddleware
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(environment)
15
+ app.call(environment)
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ safely_report(e, environment)
18
+ raise
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :app
24
+
25
+ def safely_report(error, environment)
26
+ request = Rack::Request.new(environment)
27
+ BugReporting.safely_report(
28
+ error,
29
+ environment: rack_environment(environment),
30
+ http_method: request.request_method,
31
+ parameters: { query: request.GET },
32
+ remote_ip: request.ip,
33
+ request_body: parsed_request_body(request),
34
+ url: request.url,
35
+ user_agent: request.user_agent
36
+ )
37
+ rescue StandardError => e
38
+ BugReporting.current&.log_reporting_error(e)
39
+ BugReporting.safely_report(error)
40
+ end
41
+
42
+ def parsed_request_body(request)
43
+ return request.POST unless request.media_type == 'application/json'
44
+
45
+ body = request.body.read
46
+ request.body.rewind
47
+ JSON.parse(body)
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ def rack_environment(environment)
53
+ environment.each_with_object({}) do |(key, value), result|
54
+ result[key] = value if key.start_with?('SERVER_', 'HTTP_') && value.is_a?(String)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tensorbuzz/api/rack_middleware'
4
+
5
+ module TensorBuzz
6
+ module Api
7
+ module Rails
8
+ def self.configure
9
+ ::Rails.application.config.middleware.use RackMiddleware
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TensorBuzz
4
+ module Api
5
+ Response = Data.define(:bug_report_id, :bug_report_instance_id, :project_id, :project_slug, :url)
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TensorBuzz
4
+ module Api
5
+ class Sanitizer
6
+ MAX_ARRAY_LENGTH = 100
7
+ MAX_DEPTH = 8
8
+ MAX_HASH_KEYS = 100
9
+ MAX_STRING_LENGTH = 4_000
10
+ SENSITIVE_KEY = /(authorization|cookie|password|secret|session|token)/i
11
+
12
+ def self.call(value, depth: 0, key: nil)
13
+ return '[redacted]' if key.to_s.match?(SENSITIVE_KEY)
14
+ return '[maximum depth reached]' if depth >= MAX_DEPTH
15
+
16
+ case value
17
+ when Array
18
+ value.first(MAX_ARRAY_LENGTH).map { |item| call(item, depth: depth + 1) }
19
+ when Hash
20
+ value.first(MAX_HASH_KEYS).to_h do |child_key, child_value|
21
+ [child_key.to_s, call(child_value, depth: depth + 1, key: child_key)]
22
+ end
23
+ when String
24
+ truncate(value)
25
+ when Numeric, TrueClass, FalseClass, NilClass
26
+ value
27
+ else
28
+ truncate(value.to_s)
29
+ end
30
+ end
31
+
32
+ def self.truncate(value)
33
+ return value if value.length <= MAX_STRING_LENGTH
34
+
35
+ "#{value[0, MAX_STRING_LENGTH]}[truncated #{value.length - MAX_STRING_LENGTH} characters]"
36
+ end
37
+
38
+ private_class_method :truncate
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TensorBuzz
4
+ module Api
5
+ module Sidekiq
6
+ def self.configure
7
+ require 'sidekiq'
8
+
9
+ ::Sidekiq.configure_server do |config|
10
+ config.error_handlers << proc do |error, context, _configuration = nil|
11
+ BugReporting.safely_report(error, parameters: { sidekiq: context })
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module TensorBuzz
8
+ module Api
9
+ class ReportingError < StandardError; end
10
+
11
+ class Transport
12
+ def initialize(configuration)
13
+ @configuration = configuration
14
+ end
15
+
16
+ def post(payload)
17
+ uri = URI(configuration.post_url)
18
+ http = Net::HTTP.new(uri.host, uri.port)
19
+ http.use_ssl = uri.scheme == 'https'
20
+ http.open_timeout = configuration.open_timeout
21
+ http.read_timeout = configuration.read_timeout
22
+
23
+ request = Net::HTTP::Post.new(uri.request_uri)
24
+ request['Content-Type'] = 'application/json'
25
+ request.body = JSON.generate(payload)
26
+ response = http.request(request)
27
+
28
+ raise ReportingError, "TensorBuzz rejected the report with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
29
+
30
+ JSON.parse(response.body)
31
+ rescue JSON::ParserError => e
32
+ raise ReportingError, "TensorBuzz returned invalid JSON: #{e.message}"
33
+ rescue ReportingError
34
+ raise
35
+ rescue StandardError => e
36
+ raise ReportingError, "Could not report to TensorBuzz: #{e.message}"
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :configuration
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TensorBuzz
4
+ module Api
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tensorbuzz/api/version'
4
+ require 'tensorbuzz/api/configuration'
5
+ require 'tensorbuzz/api/response'
6
+ require 'tensorbuzz/api/sanitizer'
7
+ require 'tensorbuzz/api/transport'
8
+ require 'tensorbuzz/api/bug_reporting'
9
+
10
+ module TensorBuzz
11
+ BugReporting = Api::BugReporting
12
+ ReportingError = Api::ReportingError
13
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tensorbuzz-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kasper Stöckel
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Reports Ruby, Rack, Rails, and Sidekiq errors to TensorBuzz.
13
+ email:
14
+ - k@spernj.org
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/tensorbuzz/api.rb
22
+ - lib/tensorbuzz/api/bug_reporting.rb
23
+ - lib/tensorbuzz/api/configuration.rb
24
+ - lib/tensorbuzz/api/rack_middleware.rb
25
+ - lib/tensorbuzz/api/rails.rb
26
+ - lib/tensorbuzz/api/response.rb
27
+ - lib/tensorbuzz/api/sanitizer.rb
28
+ - lib/tensorbuzz/api/sidekiq.rb
29
+ - lib/tensorbuzz/api/transport.rb
30
+ - lib/tensorbuzz/api/version.rb
31
+ homepage: https://github.com/kaspernj/tensorbuzz
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ rubygems_mfa_required: 'true'
36
+ source_code_uri: https://github.com/kaspernj/tensorbuzz/tree/master/tensorbuzz-api-ruby
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.2'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.6.9
52
+ specification_version: 4
53
+ summary: Ruby client for TensorBuzz bug reporting.
54
+ test_files: []