logbrew-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: 8171c73805b0809b5be561b9e315e64db5e49dc7bcc67df243a35e675bda9d36
4
+ data.tar.gz: e6884f90cbcc65af3873aed8aa7f2c5fa1ba3a53031d8c4ac4c3632fd2fc5a3d
5
+ SHA512:
6
+ metadata.gz: 2418916010dcadc2b7df1f584111f1e8cc9aa293b74a1eb905c12b74848a95fa092e681ee6e6d30efc237992192fc21b0463abd7301ab23df12382f10b048f21
7
+ data.tar.gz: 2e4a4fba1bd44796895e88394bc6e12da80370a1946b36e36b1c97eb87a5ebd1db39a30d9ef55414ffecca282b9f4f6eb1ac7338b5bb236e358004aca11ce6e3
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # LogBrew Ruby SDK
2
+
3
+ Public Ruby SDK for building, validating, previewing, and flushing LogBrew event batches, with standard-library `Net::HTTP` delivery, opt-in standard-library `Logger` support, Rack-compatible middleware, and a Rails error subscriber for Rails apps.
4
+
5
+ The package uses only Ruby standard-library features at runtime. The repository checks build the gem, inspect the artifact, install it into a fresh gem home, run shipped examples, and exercise HTTP delivery plus failure/lifecycle paths like a real user.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install logbrew-sdk
11
+ ```
12
+
13
+ For local testing from this repository:
14
+
15
+ ```bash
16
+ bash scripts/check_ruby_package.sh
17
+ bash scripts/real_user_ruby_smoke.sh
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ruby
23
+ require "logbrew"
24
+
25
+ client = LogBrew::Client.create(
26
+ api_key: "LOGBREW_API_KEY",
27
+ sdk_name: "my-ruby-app",
28
+ sdk_version: "1.0.0"
29
+ )
30
+
31
+ client.release(
32
+ "evt_release_001",
33
+ "2026-06-02T10:00:00Z",
34
+ version: "1.2.3",
35
+ commit: "abc123def456"
36
+ )
37
+ client.action(
38
+ "evt_action_001",
39
+ "2026-06-02T10:00:05Z",
40
+ name: "deploy",
41
+ status: "success"
42
+ )
43
+
44
+ puts client.preview_json
45
+ response = client.shutdown(LogBrew::RecordingTransport.always_accept)
46
+ warn response.status_code
47
+ ```
48
+
49
+ ## HTTP Delivery
50
+
51
+ Use `LogBrew::HttpTransport` when you want the SDK to POST queued batches to LogBrew:
52
+
53
+ ```ruby
54
+ require "logbrew"
55
+
56
+ client = LogBrew::Client.create(
57
+ api_key: "LOGBREW_API_KEY",
58
+ sdk_name: "my-ruby-app",
59
+ sdk_version: "1.0.0"
60
+ )
61
+ client.log("evt_log_001", "2026-06-02T10:00:03Z", message: "worker started", level: "info")
62
+
63
+ transport = LogBrew::HttpTransport.new(
64
+ endpoint: LogBrew::HttpTransport::DEFAULT_ENDPOINT,
65
+ headers: { "x-logbrew-source" => "ruby-worker" },
66
+ timeout: 10
67
+ )
68
+
69
+ response = client.shutdown(transport)
70
+ warn response.status_code
71
+ ```
72
+
73
+ `HttpTransport` sends JSON with the SDK key in the `authorization` header, supports a custom endpoint, headers, timeout, and app-owned HTTP client object, maps HTTP statuses through the client's retry rules, and converts request/time-out failures into retryable transport errors.
74
+
75
+ ## Examples
76
+
77
+ From `ruby/logbrew-ruby`:
78
+
79
+ ```bash
80
+ cd examples && make
81
+ cd examples && make run-readme-example
82
+ cd examples && make run
83
+ cd examples && make run-real-user-smoke
84
+ ruby examples/readme_example.rb
85
+ ruby examples/real_user_smoke.rb
86
+ ```
87
+
88
+ `make run` is the shorter alias for the stronger real-user smoke example.
89
+
90
+ ## Standard Logger
91
+
92
+ `LogBrew::Logger` subclasses Ruby's standard `::Logger`, so existing Ruby logging calls can queue LogBrew log events without adding a runtime dependency.
93
+
94
+ ```ruby
95
+ require "logbrew"
96
+
97
+ client = LogBrew::Client.create(
98
+ api_key: "LOGBREW_API_KEY",
99
+ sdk_name: "my-ruby-app",
100
+ sdk_version: "1.0.0"
101
+ )
102
+
103
+ logger = LogBrew::Logger.new(
104
+ client: client,
105
+ logger_name: "checkout",
106
+ progname: "checkout",
107
+ metadata: { service: "web" }
108
+ )
109
+
110
+ logger.warn("checkout slow")
111
+ logger.error(RuntimeError.new("payment failed"))
112
+
113
+ client.flush(LogBrew::RecordingTransport.always_accept)
114
+ ```
115
+
116
+ The adapter respects Ruby logger levels and lazy block messages, maps `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`, and `UNKNOWN` to LogBrew log levels, captures `progname`, primitive base metadata, and exception type/message, and omits exception backtrace text unless `include_exception_backtrace: true` is set. Logs queue by default; pass `transport:` plus `flush_on_log: true` or call `flush_logbrew` for immediate delivery.
117
+
118
+ ## Rack And Rails Middleware
119
+
120
+ Use `LogBrew::RackMiddleware` when a Rails, Sinatra, or Rack app should capture request spans and unhandled app exceptions without adding a framework dependency to the SDK.
121
+
122
+ ```ruby
123
+ require "logbrew"
124
+
125
+ client = LogBrew::Client.create(
126
+ api_key: "LOGBREW_API_KEY",
127
+ sdk_name: "my-rails-app",
128
+ sdk_version: "1.0.0"
129
+ )
130
+
131
+ # Rails: config/application.rb
132
+ config.middleware.use(
133
+ LogBrew::RackMiddleware,
134
+ client: client,
135
+ transport: LogBrew::HttpTransport.new,
136
+ flush_on_response: true,
137
+ metadata: { service: "web" }
138
+ )
139
+ ```
140
+
141
+ For plain Rack apps, wrap the app directly:
142
+
143
+ ```ruby
144
+ app = LogBrew::RackMiddleware.new(
145
+ ->(_env) { [200, { "content-type" => "text/plain" }, ["ok"]] },
146
+ client: client
147
+ )
148
+ ```
149
+
150
+ The middleware records successful responses as span events, records unhandled app exceptions as issue plus error-span events, and re-raises app exceptions so Rails or Rack keeps normal response handling. It captures method, path without query text, status code, request id when present, primitive base metadata, exception type/message, and duration. Exception backtrace text is omitted unless `include_exception_backtrace: true` is set. Events queue by default; pass `transport:` plus `flush_on_response: true` when each response should flush.
151
+
152
+ ## Rails Error Subscriber
153
+
154
+ Use `LogBrew::RailsErrorSubscriber` when handled or manually reported Rails errors should queue LogBrew issue events through Rails' own error reporter.
155
+
156
+ ```ruby
157
+ require "logbrew"
158
+
159
+ client = LogBrew::Client.create(
160
+ api_key: "LOGBREW_API_KEY",
161
+ sdk_name: "my-rails-app",
162
+ sdk_version: "1.0.0"
163
+ )
164
+
165
+ # Rails: config/initializers/logbrew.rb
166
+ Rails.error.subscribe(
167
+ LogBrew::RailsErrorSubscriber.new(
168
+ client: client,
169
+ transport: LogBrew::HttpTransport.new,
170
+ flush_on_report: true,
171
+ metadata: { service: "web" }
172
+ )
173
+ )
174
+ ```
175
+
176
+ The subscriber implements `report(error, handled:, severity:, context:, source:, **options)`, captures handled state, severity, Rails source, primitive context values, primitive base metadata, and exception type/message, and omits exception backtrace text unless `include_exception_backtrace: true` is set. It queues by default; pass `transport:` plus `flush_on_report: true` when each report should flush. If you also use `LogBrew::RackMiddleware`, keep the subscriber focused on handled/manual reports so unhandled request exceptions are not captured twice.
177
+
178
+ ## Behavior
179
+
180
+ - `preview_json` returns the queued batch as pretty JSON.
181
+ - `flush(transport)` sends queued events, retries retryable failures, and clears the queue only after a 2xx response.
182
+ - `LogBrew::HttpTransport` sends queued batches through Ruby's standard `Net::HTTP` with configurable endpoint, headers, timeout, and app-owned HTTP client support.
183
+ - `LogBrew::RackMiddleware` captures Rack request spans and unhandled app exceptions without requiring Rails or Rack at runtime.
184
+ - `LogBrew::RailsErrorSubscriber` captures handled/manual Rails error reports without requiring Rails at runtime.
185
+ - `shutdown(transport)` flushes queued events and rejects later writes.
186
+ - `LogBrew::RecordingTransport.always_accept` is useful for local examples and tests.
187
+ - `LogBrew::SdkError` exposes stable `code` and `message` values for user-facing failure handling.
data/examples/Makefile ADDED
@@ -0,0 +1,14 @@
1
+ .PHONY: help run run-readme-example run-real-user-smoke
2
+
3
+ help:
4
+ @printf '%s\n' 'run-readme-example -> make run-readme-example'
5
+ @printf '%s\n' 'run (real-user-smoke) -> make run'
6
+ @printf '%s\n' 'run-real-user-smoke -> make run-real-user-smoke'
7
+
8
+ run: run-real-user-smoke
9
+
10
+ run-readme-example:
11
+ @ruby readme_example.rb
12
+
13
+ run-real-user-smoke:
14
+ @ruby real_user_smoke.rb
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "logbrew"
5
+ rescue LoadError
6
+ require_relative "../lib/logbrew"
7
+ end
8
+
9
+ def enqueue_all(client)
10
+ client.release(
11
+ "evt_release_001",
12
+ "2026-06-02T10:00:00Z",
13
+ version: "1.2.3",
14
+ commit: "abc123def456",
15
+ notes: "Public release marker"
16
+ )
17
+ client.environment(
18
+ "evt_environment_001",
19
+ "2026-06-02T10:00:01Z",
20
+ name: "production",
21
+ region: "global"
22
+ )
23
+ client.issue(
24
+ "evt_issue_001",
25
+ "2026-06-02T10:00:02Z",
26
+ title: "Checkout timeout",
27
+ level: "error",
28
+ message: "Request timed out after retry budget"
29
+ )
30
+ client.log(
31
+ "evt_log_001",
32
+ "2026-06-02T10:00:03Z",
33
+ message: "worker started",
34
+ level: "info",
35
+ logger: "job-runner"
36
+ )
37
+ client.span(
38
+ "evt_span_001",
39
+ "2026-06-02T10:00:04Z",
40
+ name: "GET /health",
41
+ traceId: "trace_001",
42
+ spanId: "span_001",
43
+ status: "ok",
44
+ durationMs: 12.5
45
+ )
46
+ client.action(
47
+ "evt_action_001",
48
+ "2026-06-02T10:00:05Z",
49
+ name: "deploy",
50
+ status: "success"
51
+ )
52
+ end
53
+
54
+ if __FILE__ == $PROGRAM_NAME
55
+ client = LogBrew::Client.create(
56
+ api_key: "LOGBREW_API_KEY",
57
+ sdk_name: "logbrew-ruby",
58
+ sdk_version: "0.1.0"
59
+ )
60
+ enqueue_all(client)
61
+
62
+ puts client.preview_json
63
+ response = client.shutdown(LogBrew::RecordingTransport.always_accept)
64
+ $stderr.puts JSON.generate(ok: true, status: response.status_code, attempts: response.attempts, events: 6)
65
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ begin
6
+ require "logbrew"
7
+ rescue LoadError
8
+ require_relative "../lib/logbrew"
9
+ end
10
+ require_relative "readme_example"
11
+
12
+ client = LogBrew::Client.create(
13
+ api_key: "LOGBREW_API_KEY",
14
+ sdk_name: "logbrew-ruby",
15
+ sdk_version: "0.1.0"
16
+ )
17
+ enqueue_all(client)
18
+
19
+ puts client.preview_json
20
+ response = client.shutdown(LogBrew::RecordingTransport.always_accept)
21
+
22
+ retry_client = LogBrew::Client.create(
23
+ api_key: "LOGBREW_API_KEY",
24
+ sdk_name: "logbrew-ruby",
25
+ sdk_version: "0.1.0"
26
+ )
27
+ enqueue_all(retry_client)
28
+ retry_response = retry_client.flush(
29
+ LogBrew::RecordingTransport.new([LogBrew::TransportError.network("temporary outage"), 202])
30
+ )
31
+
32
+ rejected_after_shutdown = false
33
+ begin
34
+ client.action("evt_action_002", "2026-06-02T10:00:06Z", name: "deploy", status: "success")
35
+ rescue LogBrew::SdkError => error
36
+ rejected_after_shutdown = error.code == "shutdown_error"
37
+ end
38
+
39
+ $stderr.puts JSON.generate(
40
+ ok: rejected_after_shutdown,
41
+ status: response.status_code,
42
+ attempts: response.attempts,
43
+ retryAttempts: retry_response.attempts,
44
+ events: 6
45
+ )
data/lib/logbrew.rb ADDED
@@ -0,0 +1,936 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+ require "net/http"
6
+ require "securerandom"
7
+ require "time"
8
+ require "timeout"
9
+ require "uri"
10
+
11
+ module LogBrew
12
+ ISSUE_LEVELS = %w[info warning error critical].freeze
13
+ LOG_LEVELS = %w[debug info warning error].freeze
14
+ SPAN_STATUSES = %w[ok error].freeze
15
+ ACTION_STATUSES = %w[queued running success failure].freeze
16
+
17
+ class SdkError < StandardError
18
+ attr_reader :code
19
+
20
+ def initialize(code, message)
21
+ @code = code
22
+ super("#{code}: #{message}")
23
+ end
24
+ end
25
+
26
+ class TransportError < StandardError
27
+ attr_reader :code, :retryable
28
+
29
+ def initialize(code, message, retryable: false)
30
+ @code = code
31
+ @retryable = retryable
32
+ super(message)
33
+ end
34
+
35
+ def self.network(message)
36
+ new("network_failure", message, retryable: true)
37
+ end
38
+ end
39
+
40
+ class TransportResponse
41
+ attr_reader :status_code, :attempts
42
+
43
+ def initialize(status_code, attempts)
44
+ @status_code = status_code
45
+ @attempts = attempts
46
+ end
47
+ end
48
+
49
+ class RecordingTransport
50
+ attr_reader :sent_bodies
51
+
52
+ def initialize(scripted_responses = [202])
53
+ @scripted_responses = scripted_responses.empty? ? [202] : scripted_responses.dup
54
+ @sent_bodies = []
55
+ end
56
+
57
+ def self.always_accept
58
+ new([202])
59
+ end
60
+
61
+ def last_body
62
+ @sent_bodies[-1]
63
+ end
64
+
65
+ def send(api_key, body)
66
+ Validation.require_non_empty("api_key", api_key)
67
+ @sent_bodies << body
68
+
69
+ response = @scripted_responses.empty? ? 202 : @scripted_responses.shift
70
+ raise response if response.is_a?(TransportError)
71
+ raise response if response.is_a?(SdkError)
72
+
73
+ status_code = response.is_a?(TransportResponse) ? response.status_code : response.to_i
74
+ TransportResponse.new(status_code.zero? ? 202 : status_code, 1)
75
+ end
76
+ end
77
+
78
+ class HttpTransport
79
+ DEFAULT_ENDPOINT = "https://api.logbrew.com/v1/events"
80
+ DEFAULT_TIMEOUT = 10
81
+
82
+ attr_reader :endpoint, :headers, :timeout, :http_client
83
+
84
+ def initialize(endpoint: DEFAULT_ENDPOINT, headers: {}, timeout: DEFAULT_TIMEOUT, http_client: nil)
85
+ @endpoint = validate_endpoint(endpoint)
86
+ @headers = copy_headers(headers)
87
+ @timeout = validate_timeout(timeout)
88
+ @http_client = http_client
89
+ end
90
+
91
+ def send(api_key, body)
92
+ Validation.require_non_empty("api_key", api_key)
93
+ raise SdkError.new("validation_error", "body must be non-empty") if body.nil?
94
+
95
+ request = Net::HTTP::Post.new(request_path)
96
+ request["authorization"] = "Bearer #{api_key}"
97
+ request["content-type"] = "application/json"
98
+ @headers.each { |name, value| request[name] = value }
99
+ request.body = body
100
+
101
+ response = @http_client ? @http_client.request(request) : request_with_default_client(request)
102
+ TransportResponse.new(response.code.to_i, 1)
103
+ rescue TransportError
104
+ raise
105
+ rescue IOError, SystemCallError, SocketError, Timeout::Error, EOFError, Net::OpenTimeout, Net::ReadTimeout => error
106
+ raise TransportError.network("http transport failed: #{error.message}")
107
+ end
108
+
109
+ private
110
+
111
+ def request_path
112
+ path = @endpoint.path.empty? ? "/" : @endpoint.path
113
+ return path if @endpoint.query.nil? || @endpoint.query.empty?
114
+
115
+ "#{path}?#{@endpoint.query}"
116
+ end
117
+
118
+ def request_with_default_client(request)
119
+ http = Net::HTTP.new(@endpoint.host, @endpoint.port)
120
+ http.use_ssl = @endpoint.scheme == "https"
121
+ http.open_timeout = @timeout
122
+ http.read_timeout = @timeout
123
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
124
+ http.start { |client| client.request(request) }
125
+ end
126
+
127
+ def validate_endpoint(endpoint)
128
+ uri = endpoint.is_a?(URI) ? endpoint : URI.parse(endpoint.to_s)
129
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
130
+ raise SdkError.new("configuration_error", "HTTP transport endpoint must use http or https")
131
+ end
132
+
133
+ uri
134
+ rescue URI::InvalidURIError => error
135
+ raise SdkError.new("configuration_error", "invalid HTTP transport endpoint: #{error.message}")
136
+ end
137
+
138
+ def copy_headers(headers)
139
+ raise SdkError.new("configuration_error", "HTTP transport headers must be an object") unless headers.is_a?(Hash)
140
+
141
+ headers.each_with_object({}) do |(name, value), copied|
142
+ normalized_name = name.to_s
143
+ raise SdkError.new("configuration_error", "HTTP transport header name must be non-empty") if normalized_name.strip.empty?
144
+ raise SdkError.new("configuration_error", "HTTP transport header value must be non-null") if value.nil?
145
+
146
+ copied[normalized_name] = value.to_s
147
+ end
148
+ end
149
+
150
+ def validate_timeout(timeout)
151
+ value = timeout.to_f
152
+ raise SdkError.new("configuration_error", "HTTP transport timeout must be positive") unless value.positive? && value.finite?
153
+
154
+ value
155
+ end
156
+ end
157
+
158
+ class Logger < ::Logger
159
+ DEFAULT_LOGGER_NAME = "ruby-logger"
160
+ SEVERITY_TO_LOGBREW_LEVEL = {
161
+ ::Logger::DEBUG => "debug",
162
+ ::Logger::INFO => "info",
163
+ ::Logger::WARN => "warning",
164
+ ::Logger::ERROR => "error",
165
+ ::Logger::FATAL => "error",
166
+ ::Logger::UNKNOWN => "error"
167
+ }.freeze
168
+
169
+ def initialize(
170
+ client:,
171
+ logdev: File::NULL,
172
+ logger_name: nil,
173
+ event_id_prefix: "ruby_log",
174
+ metadata: nil,
175
+ transport: nil,
176
+ flush_on_log: false,
177
+ include_exception_backtrace: false,
178
+ timestamp_provider: nil,
179
+ on_error: nil,
180
+ raise_errors: false,
181
+ level: ::Logger::DEBUG,
182
+ progname: nil,
183
+ formatter: nil,
184
+ datetime_format: nil
185
+ )
186
+ Validation.require_non_empty("logger name", logger_name) unless logger_name.nil?
187
+ Validation.require_non_empty("event id prefix", event_id_prefix)
188
+ raise SdkError.new("validation_error", "metadata must be an object") unless metadata.nil? || metadata.is_a?(Hash)
189
+
190
+ @client = client
191
+ @logger_name = logger_name
192
+ @event_id_prefix = event_id_prefix
193
+ @metadata = metadata || {}
194
+ @transport = transport
195
+ @flush_on_log = flush_on_log
196
+ @include_exception_backtrace = include_exception_backtrace
197
+ @timestamp_provider = timestamp_provider
198
+ @on_error = on_error
199
+ @raise_errors = raise_errors
200
+ @next_event_number = 0
201
+
202
+ super(logdev || File::NULL)
203
+ self.level = level
204
+ self.progname = progname unless progname.nil?
205
+ self.formatter = formatter unless formatter.nil?
206
+ self.datetime_format = datetime_format unless datetime_format.nil?
207
+ end
208
+
209
+ def add(severity, message = nil, progname = nil)
210
+ severity = ::Logger::UNKNOWN if severity.nil?
211
+ return true if severity < level
212
+
213
+ resolved_message, resolved_progname = resolve_log_arguments(message, progname, block_given?) do
214
+ yield
215
+ end
216
+
217
+ begin
218
+ capture_logbrew_event(severity, resolved_message, resolved_progname)
219
+ rescue StandardError => error
220
+ handle_logbrew_error(error)
221
+ end
222
+
223
+ super(severity, resolved_message, resolved_progname)
224
+ end
225
+
226
+ def flush_logbrew(transport = @transport)
227
+ return nil if transport.nil? || @client.pending_events.zero?
228
+
229
+ @client.flush(transport)
230
+ end
231
+
232
+ private
233
+
234
+ def resolve_log_arguments(message, progname, has_block)
235
+ effective_progname = progname.nil? ? self.progname : progname
236
+ return [message, effective_progname] unless message.nil?
237
+
238
+ if has_block
239
+ [yield, effective_progname]
240
+ else
241
+ [effective_progname, self.progname]
242
+ end
243
+ end
244
+
245
+ def capture_logbrew_event(severity, message, progname)
246
+ @next_event_number += 1
247
+ @client.log(
248
+ "#{@event_id_prefix}_#{@next_event_number}",
249
+ logbrew_timestamp,
250
+ message: logbrew_message(message),
251
+ level: logbrew_level(severity),
252
+ logger: event_logger_name(progname),
253
+ metadata: logbrew_metadata(severity, message, progname)
254
+ )
255
+ flush_logbrew if @flush_on_log
256
+ end
257
+
258
+ def handle_logbrew_error(error)
259
+ @on_error.call(error) if @on_error.respond_to?(:call)
260
+ raise error if @raise_errors
261
+ end
262
+
263
+ def logbrew_timestamp
264
+ timestamp = @timestamp_provider.respond_to?(:call) ? @timestamp_provider.call : Time.now
265
+ return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
266
+
267
+ timestamp.to_s
268
+ end
269
+
270
+ def logbrew_message(message)
271
+ return message.message if message.is_a?(Exception)
272
+ return message if message.is_a?(String)
273
+
274
+ message.inspect
275
+ end
276
+
277
+ def logbrew_level(severity)
278
+ SEVERITY_TO_LOGBREW_LEVEL.fetch(severity.to_i, severity.to_i >= ::Logger::WARN ? "error" : "info")
279
+ end
280
+
281
+ def event_logger_name(progname)
282
+ configured = @logger_name || progname || self.progname || DEFAULT_LOGGER_NAME
283
+ configured.to_s
284
+ end
285
+
286
+ def logbrew_metadata(severity, message, progname)
287
+ copy_metadata(@metadata).tap do |metadata|
288
+ metadata["rubySeverity"] = severity_label(severity)
289
+ metadata["progname"] = progname.to_s if primitive_metadata_value?(progname) && !progname.to_s.empty?
290
+ add_exception_metadata(metadata, message) if message.is_a?(Exception)
291
+ end
292
+ end
293
+
294
+ def severity_label(severity)
295
+ ::Logger::SEV_LABEL[severity.to_i] || "ANY"
296
+ end
297
+
298
+ def add_exception_metadata(metadata, exception)
299
+ metadata["exceptionType"] = exception.class.name
300
+ metadata["exceptionMessage"] = exception.message
301
+ metadata["exceptionBacktrace"] = exception.backtrace.join("\n") if @include_exception_backtrace && exception.backtrace
302
+ end
303
+
304
+ def copy_metadata(metadata)
305
+ metadata.each_with_object({}) do |(key, value), copied|
306
+ copied[key.to_s] = value if primitive_metadata_value?(value)
307
+ end
308
+ end
309
+
310
+ def primitive_metadata_value?(value)
311
+ return true if value.nil? || value == true || value == false
312
+ return true if value.is_a?(String) || value.is_a?(Integer)
313
+
314
+ value.is_a?(Float) && value.finite?
315
+ end
316
+ end
317
+
318
+ # Rack-compatible middleware for Rails, Sinatra, and other Rack-based Ruby apps.
319
+ #
320
+ # The middleware captures completed requests as span events and unhandled app
321
+ # exceptions as issue plus error-span events. It does not require the rack or
322
+ # rails gems at runtime; any app object that responds to `call(env)` is enough.
323
+ class RackMiddleware
324
+ DEFAULT_EVENT_ID_PREFIX = "ruby_rack"
325
+ DEFAULT_SPAN_LOGGER = "rack"
326
+
327
+ def initialize(
328
+ app,
329
+ client:,
330
+ transport: nil,
331
+ flush_on_response: false,
332
+ event_id_prefix: DEFAULT_EVENT_ID_PREFIX,
333
+ metadata: nil,
334
+ timestamp_provider: nil,
335
+ include_exception_backtrace: false,
336
+ on_error: nil,
337
+ raise_errors: false
338
+ )
339
+ raise SdkError.new("validation_error", "rack app must respond to call") unless app.respond_to?(:call)
340
+ Validation.require_non_empty("event id prefix", event_id_prefix)
341
+ raise SdkError.new("validation_error", "metadata must be an object") unless metadata.nil? || metadata.is_a?(Hash)
342
+
343
+ @app = app
344
+ @client = client
345
+ @transport = transport
346
+ @flush_on_response = flush_on_response
347
+ @event_id_prefix = event_id_prefix
348
+ @metadata = metadata || {}
349
+ @timestamp_provider = timestamp_provider
350
+ @include_exception_backtrace = include_exception_backtrace
351
+ @on_error = on_error
352
+ @raise_errors = raise_errors
353
+ @next_event_number = 0
354
+ end
355
+
356
+ def call(env)
357
+ started_at = monotonic_time
358
+ begin
359
+ response = @app.call(env)
360
+ rescue StandardError => error
361
+ safely_capture do
362
+ elapsed_ms = duration_ms(started_at)
363
+ capture_exception_issue(env, error)
364
+ capture_request_span(env, 500, elapsed_ms, "error")
365
+ flush_if_configured
366
+ end
367
+ raise
368
+ end
369
+
370
+ status_code = rack_status(response)
371
+ safely_capture do
372
+ capture_request_span(env, status_code, duration_ms(started_at), status_code >= 500 ? "error" : "ok")
373
+ flush_if_configured
374
+ end
375
+ response
376
+ end
377
+
378
+ private
379
+
380
+ def capture_request_span(env, status_code, elapsed_ms, status)
381
+ @client.span(
382
+ next_event_id("span"),
383
+ logbrew_timestamp,
384
+ name: request_name(env),
385
+ traceId: trace_id(env),
386
+ spanId: span_id(env),
387
+ status: status,
388
+ durationMs: elapsed_ms,
389
+ metadata: request_metadata(env, status_code)
390
+ )
391
+ end
392
+
393
+ def capture_exception_issue(env, error)
394
+ @client.issue(
395
+ next_event_id("issue"),
396
+ logbrew_timestamp,
397
+ title: error.class.name,
398
+ level: "error",
399
+ message: error.message,
400
+ metadata: exception_metadata(env, error)
401
+ )
402
+ end
403
+
404
+ def next_event_id(kind)
405
+ @next_event_number += 1
406
+ "#{@event_id_prefix}_#{kind}_#{@next_event_number}"
407
+ end
408
+
409
+ def logbrew_timestamp
410
+ timestamp = @timestamp_provider.respond_to?(:call) ? @timestamp_provider.call : Time.now
411
+ return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
412
+
413
+ timestamp.to_s
414
+ end
415
+
416
+ def request_name(env)
417
+ "#{request_method(env)} #{request_path(env)}"
418
+ end
419
+
420
+ def request_method(env)
421
+ value = env_value(env, "REQUEST_METHOD")
422
+ value.nil? || value.empty? ? "GET" : value
423
+ end
424
+
425
+ def request_path(env)
426
+ value = env_value(env, "PATH_INFO")
427
+ value = env_value(env, "REQUEST_PATH") if value.nil? || value.empty?
428
+ value = env_value(env, "REQUEST_URI").to_s.split("?", 2)[0] if value.nil? || value.empty?
429
+ value.nil? || value.empty? ? "/" : value
430
+ end
431
+
432
+ def trace_id(env)
433
+ env_value(env, "logbrew.trace_id") ||
434
+ env_value(env, "action_dispatch.request_id") ||
435
+ env_value(env, "HTTP_X_REQUEST_ID") ||
436
+ SecureRandom.hex(16)
437
+ end
438
+
439
+ def span_id(env)
440
+ env_value(env, "logbrew.span_id") || SecureRandom.hex(8)
441
+ end
442
+
443
+ def request_metadata(env, status_code)
444
+ copy_metadata(@metadata).tap do |metadata|
445
+ metadata["source"] = DEFAULT_SPAN_LOGGER
446
+ metadata["http.method"] = request_method(env)
447
+ metadata["http.path"] = request_path(env)
448
+ metadata["http.status_code"] = status_code
449
+ add_env_metadata(metadata, "rack.url_scheme", env)
450
+ add_env_metadata(metadata, "action_dispatch.request_id", env)
451
+ add_env_metadata(metadata, "HTTP_X_REQUEST_ID", env)
452
+ end
453
+ end
454
+
455
+ def exception_metadata(env, error)
456
+ request_metadata(env, 500).tap do |metadata|
457
+ metadata["exceptionType"] = error.class.name
458
+ metadata["exceptionMessage"] = error.message
459
+ metadata["exceptionBacktrace"] = error.backtrace.join("\n") if @include_exception_backtrace && error.backtrace
460
+ end
461
+ end
462
+
463
+ def rack_status(response)
464
+ return response[0].to_i if response.respond_to?(:[]) && !response[0].nil?
465
+
466
+ 500
467
+ end
468
+
469
+ def env_value(env, key)
470
+ return nil unless env.respond_to?(:[])
471
+
472
+ value = env[key]
473
+ return nil unless primitive_metadata_value?(value)
474
+
475
+ text = value.to_s
476
+ text.empty? ? nil : text
477
+ end
478
+
479
+ def add_env_metadata(metadata, key, env)
480
+ value = env_value(env, key)
481
+ metadata[key] = value unless value.nil?
482
+ end
483
+
484
+ def copy_metadata(metadata)
485
+ metadata.each_with_object({}) do |(key, value), copied|
486
+ copied[key.to_s] = value if primitive_metadata_value?(value)
487
+ end
488
+ end
489
+
490
+ def primitive_metadata_value?(value)
491
+ return true if value.nil? || value == true || value == false
492
+ return true if value.is_a?(String) || value.is_a?(Integer)
493
+
494
+ value.is_a?(Float) && value.finite?
495
+ end
496
+
497
+ def monotonic_time
498
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
499
+ end
500
+
501
+ def duration_ms(started_at)
502
+ ((monotonic_time - started_at) * 1000.0).round(3)
503
+ end
504
+
505
+ def flush_if_configured
506
+ return unless @flush_on_response && !@transport.nil? && @client.pending_events.positive?
507
+
508
+ @client.flush(@transport)
509
+ end
510
+
511
+ def safely_capture
512
+ yield
513
+ rescue StandardError => error
514
+ @on_error.call(error) if @on_error.respond_to?(:call)
515
+ raise error if @raise_errors
516
+ end
517
+ end
518
+
519
+ # Rails.error subscriber for handled and manually reported Rails exceptions.
520
+ #
521
+ # Register an instance with `Rails.error.subscribe(...)` from a Rails
522
+ # initializer. This class avoids a hard Rails dependency so the core gem stays
523
+ # usable in plain Ruby and Rack apps.
524
+ class RailsErrorSubscriber
525
+ DEFAULT_EVENT_ID_PREFIX = "ruby_rails_error"
526
+ SEVERITY_TO_ISSUE_LEVEL = {
527
+ "info" => "info",
528
+ "warning" => "warning",
529
+ "error" => "error"
530
+ }.freeze
531
+
532
+ def initialize(
533
+ client:,
534
+ transport: nil,
535
+ flush_on_report: false,
536
+ event_id_prefix: DEFAULT_EVENT_ID_PREFIX,
537
+ metadata: nil,
538
+ timestamp_provider: nil,
539
+ include_exception_backtrace: false,
540
+ on_error: nil,
541
+ raise_errors: false
542
+ )
543
+ Validation.require_non_empty("event id prefix", event_id_prefix)
544
+ raise SdkError.new("validation_error", "metadata must be an object") unless metadata.nil? || metadata.is_a?(Hash)
545
+
546
+ @client = client
547
+ @transport = transport
548
+ @flush_on_report = flush_on_report
549
+ @event_id_prefix = event_id_prefix
550
+ @metadata = metadata || {}
551
+ @timestamp_provider = timestamp_provider
552
+ @include_exception_backtrace = include_exception_backtrace
553
+ @on_error = on_error
554
+ @raise_errors = raise_errors
555
+ @next_event_number = 0
556
+ end
557
+
558
+ def report(error, handled: true, severity: :error, context: nil, source: nil, **_options)
559
+ capture_safely do
560
+ @next_event_number += 1
561
+ @client.issue(
562
+ "#{@event_id_prefix}_#{@next_event_number}",
563
+ logbrew_timestamp,
564
+ title: error_title(error),
565
+ level: issue_level(severity),
566
+ message: error_message(error),
567
+ metadata: rails_metadata(error, handled, severity, context, source)
568
+ )
569
+ flush_if_configured
570
+ end
571
+ end
572
+
573
+ private
574
+
575
+ def logbrew_timestamp
576
+ timestamp = @timestamp_provider.respond_to?(:call) ? @timestamp_provider.call : Time.now
577
+ return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
578
+
579
+ timestamp.to_s
580
+ end
581
+
582
+ def error_title(error)
583
+ return error.class.name if error.is_a?(Exception)
584
+
585
+ "RailsError"
586
+ end
587
+
588
+ def error_message(error)
589
+ return error.message if error.is_a?(Exception)
590
+ return error if error.is_a?(String)
591
+
592
+ error.inspect
593
+ end
594
+
595
+ def issue_level(severity)
596
+ SEVERITY_TO_ISSUE_LEVEL.fetch(severity.to_s, "error")
597
+ end
598
+
599
+ def rails_metadata(error, handled, severity, context, source)
600
+ copy_metadata(@metadata).tap do |metadata|
601
+ metadata["source"] = "rails.error"
602
+ metadata["rails.handled"] = handled ? true : false
603
+ metadata["rails.severity"] = severity.to_s
604
+ metadata["rails.source"] = source.to_s if primitive_metadata_value?(source) && !source.to_s.empty?
605
+ add_context_metadata(metadata, context)
606
+ add_exception_metadata(metadata, error) if error.is_a?(Exception)
607
+ end
608
+ end
609
+
610
+ def add_context_metadata(metadata, context)
611
+ return if context.nil?
612
+ return unless context.is_a?(Hash)
613
+
614
+ context.each do |key, value|
615
+ metadata["context.#{key}"] = value if primitive_metadata_value?(value)
616
+ end
617
+ end
618
+
619
+ def add_exception_metadata(metadata, exception)
620
+ metadata["exceptionType"] = exception.class.name
621
+ metadata["exceptionMessage"] = exception.message
622
+ metadata["exceptionBacktrace"] = exception.backtrace.join("\n") if @include_exception_backtrace && exception.backtrace
623
+ end
624
+
625
+ def copy_metadata(metadata)
626
+ metadata.each_with_object({}) do |(key, value), copied|
627
+ copied[key.to_s] = value if primitive_metadata_value?(value)
628
+ end
629
+ end
630
+
631
+ def primitive_metadata_value?(value)
632
+ return true if value.nil? || value == true || value == false
633
+ return true if value.is_a?(String) || value.is_a?(Integer)
634
+
635
+ value.is_a?(Float) && value.finite?
636
+ end
637
+
638
+ def flush_if_configured
639
+ return unless @flush_on_report && !@transport.nil? && @client.pending_events.positive?
640
+
641
+ @client.flush(@transport)
642
+ end
643
+
644
+ def capture_safely
645
+ yield
646
+ rescue StandardError => error
647
+ @on_error.call(error) if @on_error.respond_to?(:call)
648
+ raise error if @raise_errors
649
+ end
650
+ end
651
+
652
+ module Validation
653
+ module_function
654
+
655
+ def require_non_empty(label, value)
656
+ return if value.is_a?(String) && !value.strip.empty?
657
+
658
+ raise SdkError.new("validation_error", "#{label} must be non-empty")
659
+ end
660
+
661
+ def require_allowed_value(label, value, allowed_values)
662
+ require_non_empty(label, value)
663
+ return if allowed_values.include?(value)
664
+
665
+ raise SdkError.new("validation_error", "#{label} must be one of: #{allowed_values.join(', ')}")
666
+ end
667
+
668
+ def require_timestamp(timestamp)
669
+ require_non_empty("timestamp", timestamp)
670
+ return if timestamp.end_with?("Z")
671
+
672
+ time_parts = timestamp.split("T", 2)
673
+ raise timestamp_error(timestamp) if time_parts.length < 2
674
+
675
+ time_portion = time_parts[1]
676
+ return if time_portion.include?("+")
677
+ return if time_portion.rindex("-") && time_portion.rindex("-").positive?
678
+
679
+ raise timestamp_error(timestamp)
680
+ end
681
+
682
+ def require_metadata(metadata)
683
+ return nil if metadata.nil?
684
+ raise SdkError.new("validation_error", "metadata must be an object") unless metadata.is_a?(Hash)
685
+
686
+ metadata.each_with_object({}) do |(key, value), copied|
687
+ normalized_key = key.to_s
688
+ require_non_empty("metadata key", normalized_key)
689
+ unless value.nil? || value.is_a?(String) || value.is_a?(Integer) || value.is_a?(Float) ||
690
+ value == true || value == false
691
+ raise SdkError.new(
692
+ "validation_error",
693
+ "metadata value for #{normalized_key} must be a string, number, boolean, or null"
694
+ )
695
+ end
696
+ raise SdkError.new("validation_error", "metadata value for #{normalized_key} must be finite") if numeric_nan?(value)
697
+
698
+ copied[normalized_key] = value
699
+ end
700
+ end
701
+
702
+ def read(attributes, key)
703
+ return nil unless attributes.is_a?(Hash)
704
+
705
+ attributes[key] || attributes[key.to_sym]
706
+ end
707
+
708
+ def timestamp_error(timestamp)
709
+ SdkError.new("validation_error", "timestamp must include a timezone offset: #{timestamp}")
710
+ end
711
+
712
+ def numeric_nan?(value)
713
+ value.is_a?(Float) && (value.nan? || value.infinite?)
714
+ end
715
+ end
716
+
717
+ class Client
718
+ def self.create(api_key:, sdk_name:, sdk_version:, max_retries: 2)
719
+ Validation.require_non_empty("api_key", api_key)
720
+ Validation.require_non_empty("sdk_name", sdk_name)
721
+ Validation.require_non_empty("sdk_version", sdk_version)
722
+ raise SdkError.new("validation_error", "max_retries must be non-negative") if max_retries.negative?
723
+
724
+ new(
725
+ api_key: api_key,
726
+ sdk: { "name" => sdk_name, "language" => "ruby", "version" => sdk_version },
727
+ max_retries: max_retries
728
+ )
729
+ end
730
+
731
+ def initialize(api_key:, sdk:, max_retries:)
732
+ @api_key = api_key
733
+ @sdk = sdk
734
+ @max_retries = max_retries
735
+ @events = []
736
+ @closed = false
737
+ end
738
+
739
+ def pending_events
740
+ @events.length
741
+ end
742
+
743
+ def preview_json
744
+ JSON.pretty_generate("sdk" => @sdk, "events" => @events)
745
+ end
746
+
747
+ def release(id, timestamp, attributes)
748
+ push_event("release", id, timestamp, validate_release(attributes))
749
+ end
750
+
751
+ def environment(id, timestamp, attributes)
752
+ push_event("environment", id, timestamp, validate_environment(attributes))
753
+ end
754
+
755
+ def issue(id, timestamp, attributes)
756
+ push_event("issue", id, timestamp, validate_issue(attributes))
757
+ end
758
+
759
+ def log(id, timestamp, attributes)
760
+ push_event("log", id, timestamp, validate_log(attributes))
761
+ end
762
+
763
+ def span(id, timestamp, attributes)
764
+ push_event("span", id, timestamp, validate_span(attributes))
765
+ end
766
+
767
+ def action(id, timestamp, attributes)
768
+ push_event("action", id, timestamp, validate_action(attributes))
769
+ end
770
+
771
+ def flush(transport)
772
+ raise SdkError.new("shutdown_error", "client is already shut down") if @closed
773
+
774
+ flush_internal(transport)
775
+ end
776
+
777
+ def shutdown(transport)
778
+ raise SdkError.new("shutdown_error", "client is already shut down") if @closed
779
+
780
+ response = flush_internal(transport)
781
+ @closed = true
782
+ response
783
+ end
784
+
785
+ private
786
+
787
+ def push_event(type, id, timestamp, attributes)
788
+ raise SdkError.new("shutdown_error", "client is already shut down") if @closed
789
+
790
+ Validation.require_non_empty("event id", id)
791
+ Validation.require_timestamp(timestamp)
792
+ @events << {
793
+ "type" => type,
794
+ "timestamp" => timestamp,
795
+ "id" => id,
796
+ "attributes" => attributes
797
+ }
798
+ end
799
+
800
+ def flush_internal(transport)
801
+ return TransportResponse.new(204, 0) if @events.empty?
802
+
803
+ body = preview_json
804
+ max_attempts = @max_retries + 1
805
+ (1..max_attempts).each do |attempt|
806
+ begin
807
+ response = transport.send(@api_key, body)
808
+ raise SdkError.new("unauthenticated", "transport rejected the API key") if response.status_code == 401
809
+
810
+ if response.status_code >= 200 && response.status_code < 300
811
+ @events.clear
812
+ return TransportResponse.new(response.status_code, attempt)
813
+ end
814
+ next if response.status_code >= 500 && attempt < max_attempts
815
+
816
+ raise SdkError.new("transport_error", "unexpected transport status #{response.status_code}")
817
+ rescue TransportError => error
818
+ next if error.retryable && attempt < max_attempts
819
+
820
+ raise SdkError.new(error.code, error.message)
821
+ end
822
+ end
823
+ raise SdkError.new("transport_error", "exhausted retries")
824
+ end
825
+
826
+ def validate_release(attributes)
827
+ version = Validation.read(attributes, "version")
828
+ Validation.require_non_empty("release version", version)
829
+ commit = Validation.read(attributes, "commit")
830
+ Validation.require_non_empty("release commit", commit) unless commit.nil?
831
+ with_metadata(
832
+ {
833
+ "version" => version
834
+ }.tap do |payload|
835
+ payload["commit"] = commit unless commit.nil?
836
+ notes = Validation.read(attributes, "notes")
837
+ payload["notes"] = notes unless notes.nil?
838
+ end,
839
+ attributes
840
+ )
841
+ end
842
+
843
+ def validate_environment(attributes)
844
+ name = Validation.read(attributes, "name")
845
+ Validation.require_non_empty("environment name", name)
846
+ with_metadata(
847
+ {
848
+ "name" => name
849
+ }.tap do |payload|
850
+ region = Validation.read(attributes, "region")
851
+ payload["region"] = region unless region.nil?
852
+ end,
853
+ attributes
854
+ )
855
+ end
856
+
857
+ def validate_issue(attributes)
858
+ title = Validation.read(attributes, "title")
859
+ level = Validation.read(attributes, "level")
860
+ Validation.require_non_empty("issue title", title)
861
+ Validation.require_allowed_value("issue level", level, ISSUE_LEVELS)
862
+ with_metadata(
863
+ {
864
+ "title" => title,
865
+ "level" => level
866
+ }.tap do |payload|
867
+ message = Validation.read(attributes, "message")
868
+ payload["message"] = message unless message.nil?
869
+ end,
870
+ attributes
871
+ )
872
+ end
873
+
874
+ def validate_log(attributes)
875
+ message = Validation.read(attributes, "message")
876
+ level = Validation.read(attributes, "level")
877
+ Validation.require_non_empty("log message", message)
878
+ Validation.require_allowed_value("log level", level, LOG_LEVELS)
879
+ with_metadata(
880
+ {
881
+ "message" => message,
882
+ "level" => level
883
+ }.tap do |payload|
884
+ logger = Validation.read(attributes, "logger")
885
+ payload["logger"] = logger unless logger.nil?
886
+ end,
887
+ attributes
888
+ )
889
+ end
890
+
891
+ def validate_span(attributes)
892
+ name = Validation.read(attributes, "name")
893
+ trace_id = Validation.read(attributes, "traceId")
894
+ span_id = Validation.read(attributes, "spanId")
895
+ status = Validation.read(attributes, "status")
896
+ Validation.require_non_empty("span name", name)
897
+ Validation.require_non_empty("span traceId", trace_id)
898
+ Validation.require_non_empty("span spanId", span_id)
899
+ Validation.require_allowed_value("span status", status, SPAN_STATUSES)
900
+
901
+ parent_span_id = Validation.read(attributes, "parentSpanId")
902
+ Validation.require_non_empty("span parentSpanId", parent_span_id) unless parent_span_id.nil?
903
+ duration_ms = Validation.read(attributes, "durationMs")
904
+ if !duration_ms.nil? && (!duration_ms.is_a?(Numeric) || duration_ms.negative?)
905
+ raise SdkError.new("validation_error", "span durationMs must be non-negative")
906
+ end
907
+
908
+ with_metadata(
909
+ {
910
+ "name" => name,
911
+ "traceId" => trace_id,
912
+ "spanId" => span_id
913
+ }.tap do |payload|
914
+ payload["parentSpanId"] = parent_span_id unless parent_span_id.nil?
915
+ payload["status"] = status
916
+ payload["durationMs"] = duration_ms unless duration_ms.nil?
917
+ end,
918
+ attributes
919
+ )
920
+ end
921
+
922
+ def validate_action(attributes)
923
+ name = Validation.read(attributes, "name")
924
+ status = Validation.read(attributes, "status")
925
+ Validation.require_non_empty("action name", name)
926
+ Validation.require_allowed_value("action status", status, ACTION_STATUSES)
927
+ with_metadata({ "name" => name, "status" => status }, attributes)
928
+ end
929
+
930
+ def with_metadata(payload, attributes)
931
+ metadata = Validation.require_metadata(Validation.read(attributes, "metadata"))
932
+ payload["metadata"] = metadata unless metadata.nil?
933
+ payload
934
+ end
935
+ end
936
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: logbrew-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - LogBrew
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Public LogBrew Ruby SDK for building, validating, and flushing event
14
+ batches.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - examples/Makefile
22
+ - examples/readme_example.rb
23
+ - examples/real_user_smoke.rb
24
+ - lib/logbrew.rb
25
+ homepage: https://github.com/LogBrewCo/sdk
26
+ licenses:
27
+ - MIT
28
+ metadata:
29
+ source_code_uri: https://github.com/LogBrewCo/sdk
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.6'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.5.22
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: Public LogBrew Ruby SDK
49
+ test_files: []