logbrew-sdk 0.1.17 → 0.1.18

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25c59f04169b6fce06afa15c04f790fd7b0d3d8643b50c880ca2d451e71168b2
4
- data.tar.gz: dec9fb58c8ba57ad3745227d5858a400947a41e62c2fea3ce66d9d9096495372
3
+ metadata.gz: '050529417548746276ff875602c06b58d1356614727cab8713094be67d46bce9'
4
+ data.tar.gz: 9ce8b6b4a0873ca92907618d684a161f3a9151fcff19b19bb64efa133613263f
5
5
  SHA512:
6
- metadata.gz: 251d55bf921e65cd161648547aa60a9ac6242aee7659b48ef674d87e42bda61d6dea4234b7e164f2a7d996dd9af92b32cc54315bde1b5edc37a765a68bc00b36
7
- data.tar.gz: 3a66a2414a69c0f85a80050f772137b85482d2212a926f5d78b6a67edab7f73394473c27b1a1ceffa54c0eea59be98d7c47048ca1a1c25739b60d9526b9e0ee1
6
+ metadata.gz: e840df499c2e6415d03f7fb86c0676b8312f81d52936c0bc8490ec7ce59a21f0ac121d2fc584d1f798dccdc2a1abd48e73abcc557be8a625ffed9663baef8704
7
+ data.tar.gz: 98e277bf3ffd8ae3296c3179fd8f0ceaa2e501bfd8f5311ea300bc23971989225bb31f63928d8d9c1c90198feca0ba0baeb9615b6a2d31d2e6a4e83cfced48f4
data/README.md CHANGED
@@ -23,7 +23,7 @@ needed:
23
23
 
24
24
  ```ruby
25
25
  # Gemfile
26
- gem "logbrew-sdk", "~> 0.1.17"
26
+ gem "logbrew-sdk", "~> 0.1.18"
27
27
  ```
28
28
 
29
29
  ```bash
@@ -560,7 +560,7 @@ source code, locals, arguments, or absolute paths. The raw Rails/Rack backtrace
560
560
  option is separate and remains off by default. Run
561
561
  `make -C examples run-issue-diagnostics` for a complete inspectable payload.
562
562
  Rails, Rack, and Sidekiq issue capture reuse this same graph. See the shared
563
- [exception-chain contract](../../docs/exception-chain-evidence.md).
563
+ [exception-chain contract](../../README.md#exception-chains).
564
564
 
565
565
  ## Metrics
566
566
 
@@ -621,7 +621,7 @@ The helpers return normal `action` attributes, so they work with the existing qu
621
621
 
622
622
  ## Support Ticket Draft Diagnostics
623
623
 
624
- Use `LogBrew::SupportTicketDraft.create` when a developer or support agent explicitly asks for a local JSON payload for the planned LogBrew support-ticket routes. The helper validates the public source/category contract, normalizes W3C trace IDs, redacts diagnostics, and returns a plain Ruby hash:
624
+ Use `LogBrew::SupportTicketDraft.create` to build the JSON body for a LogBrew support ticket. The helper validates the source and category, normalizes W3C trace IDs, redacts diagnostics, and returns a plain Ruby hash:
625
625
 
626
626
  ```ruby
627
627
  draft = LogBrew::SupportTicketDraft.create(
@@ -642,7 +642,7 @@ draft = LogBrew::SupportTicketDraft.create(
642
642
  puts JSON.generate(draft)
643
643
  ```
644
644
 
645
- This helper is local-only. It does not send data, open a ticket, call backend support-ticket routes, use account/session API credentials, or infer backend ownership. Diagnostics are bounded to JSON-like values; token-like keys and strings are redacted, HTTP URLs keep only the path, local filesystem paths are replaced, exceptions keep only the class name, and unsupported Ruby objects are omitted.
645
+ The helper sends nothing. To open the ticket, send it to `POST /api/support/tickets` or run `logbrew support create`. See [Report a problem](https://docs.logbrew.co/guides/report-a-problem). Diagnostics are bounded to JSON-like values; token-like keys and strings are redacted, HTTP URLs keep only the path, local filesystem paths are replaced, exceptions keep only the class name, and unsupported Ruby objects are omitted.
646
646
 
647
647
  ## HTTP Delivery
648
648
 
@@ -724,7 +724,9 @@ client.shutdown
724
724
 
725
725
  The worker starts only after accepted queue work exists, then wakes when the queue reaches `flush_threshold` or `flush_interval` expires. The defaults are 1 second and 100 events, so a single event is readable in about a second while bursts still share one request. Restart-hydrated persistent work wakes it immediately. Automatic and manual sends share the existing serialized flush, immutable failed prefix, accepted-prefix acknowledgement, batch bounds, and persistence format; no second queue or transport path is created.
726
726
 
727
- Retryable network, `408`, and `5xx` failures retain the exact failed body and use capped equal-jitter backoff. Authentication (`401`/`403`), quota (`429`), validation (`400`/`422`), and other non-retryable responses pause automatic sends without dropping queued work. `recover_automatic_delivery` performs one explicit synchronous flush through the owned transport and resumes scheduling only after success. Calling `flush(transport)` directly provides the same explicit recovery boundary. `stop_automatic_delivery` joins the worker without draining or discarding work; a later manual flush remains available.
727
+ Retryable network, `408`, and `5xx` failures retain the exact failed body and use capped equal-jitter backoff. Authentication (`401`/`403`), quota (`429`, described below), validation (`400`/`422`), and other non-retryable responses pause automatic sends without dropping queued work. `recover_automatic_delivery` performs one explicit synchronous flush through the owned transport and resumes scheduling only after success. Calling `flush(transport)` directly provides the same explicit recovery boundary. `stop_automatic_delivery` joins the worker without draining or discarding work; a later manual flush remains available.
728
+
729
+ A `429` response with `code: "usage_limit_exceeded"` in its JSON body pauses with the `quota` reason, even when it includes `Retry-After`. Any other `429` with a readable `Retry-After` value (delta-seconds or an HTTP date; a past date counts as zero) or with `code: "rate_limited"` starts a timed wait instead: the client waits for the `Retry-After` delay, or its usual retry backoff when the header is missing, kept between 1 second and 1 hour plus up to 10 percent of random jitter, and capped at 1 hour. The failed batch stays first in the queue and is sent again unchanged when the wait ends, without any application call, while new events keep queueing within the usual limits. During the wait `delivery_health` reports the `retrying` state with `retry_delay_ms`, not a pause. A `429` with neither pauses as before. `HttpTransport` reads at most 4 KiB of a `429` body only to find `code` and never keeps or reports it. A custom transport can request the same handling by returning `LogBrew::TransportResponse.new(429, 1, retry_after: seconds, code: code)`. `flush` still raises `transport_error` for any `429`.
728
730
 
729
731
  `delivery_health` returns an immutable, JSON-serializable `LogBrew::DeliveryHealth` snapshot. Its fixed fields are lifecycle state, queued event/byte counts, dropped count, in-flight state, bounded outcome/failure/flush counters, pause reason, and current retry delay. It never contains event data, event IDs, API keys, endpoints, headers, response bodies, filesystem paths, process or thread IDs, exception messages, or server text.
730
732
 
@@ -925,7 +927,7 @@ unhandled request exceptions are not captured twice.
925
927
  - Queues default to 1,000 events and 4 MiB of compact serialized event data; `pending_event_bytes`, `dropped_events`, and `on_event_dropped` expose pressure locally. `TransportResponse#attempts` and `#batches` expose request work.
926
928
  - `metric(...)` queues explicit, application-owned metric events with name, kind, value, unit, temporality, and low-cardinality metadata validation.
927
929
  - `LogBrew::ProductTimeline` builds explicit, application-owned product action and network milestone timeline events with primitive metadata and query/hash-free routes.
928
- - `LogBrew::SupportTicketDraft.create` builds explicit, local-only support-ticket create payload drafts with redacted diagnostics and no backend route calls.
930
+ - `LogBrew::SupportTicketDraft.create` builds a redacted support-ticket body and sends nothing.
929
931
  - `LogBrew::IssueDiagnostics` builds typed exception identity, mechanism/handled state, basename-only structured frames, and bounded ordered breadcrumbs without raw exception internals.
930
932
  - `LogBrew::HttpTransport` sends queued batches through Ruby's standard `Net::HTTP` with configurable endpoint, headers, timeout, and app-owned HTTP client support.
931
933
  - `LogBrew::RackMiddleware` captures Rack request spans and unhandled app exceptions without requiring Rails or Rack at runtime.
@@ -26,6 +26,8 @@ module LogBrew
26
26
  STATES = %w[idle running retrying paused stopped closing closed].freeze
27
27
  OUTCOMES = %w[none empty accepted retryable_failure terminal_failure stopped].freeze
28
28
  PAUSE_REASONS = %w[authentication quota validation nonretryable].freeze
29
+ RATE_LIMIT_MIN_WAIT = 1.0
30
+ RATE_LIMIT_MAX_WAIT = 3600.0
29
31
 
30
32
  attr_reader :transport
31
33
 
@@ -263,7 +265,7 @@ module LogBrew
263
265
  @state = "retrying"
264
266
  @last_outcome = "retryable_failure"
265
267
  @pause_reason = nil
266
- @retry_delay = bounded_retry_delay
268
+ @retry_delay = failure_retry_delay(error)
267
269
  @retry_deadline = monotonic_time + @retry_delay
268
270
  ensure_worker if @observed_queue_count.positive?
269
271
  @condition.signal
@@ -406,7 +408,7 @@ module LogBrew
406
408
  @next_interval_deadline = nil
407
409
  if retryable_failure?(error)
408
410
  @pause_reason = nil
409
- @retry_delay = bounded_retry_delay
411
+ @retry_delay = failure_retry_delay(error)
410
412
  @retry_deadline = monotonic_time + @retry_delay
411
413
  @state = "retrying"
412
414
  @condition.signal
@@ -455,6 +457,15 @@ module LogBrew
455
457
  "nonretryable"
456
458
  end
457
459
 
460
+ # A rate limit waits for Retry-After, or the usual backoff, kept within 1 s and 1 h plus up to 10% jitter.
461
+ def failure_retry_delay(error)
462
+ wait = error.respond_to?(:automatic_rate_limit) ? error.automatic_rate_limit : nil
463
+ return bounded_retry_delay if wait.nil?
464
+
465
+ delay = (wait.is_a?(Numeric) ? wait.to_f : bounded_retry_delay).clamp(RATE_LIMIT_MIN_WAIT, RATE_LIMIT_MAX_WAIT)
466
+ [delay + (Random.rand * delay * 0.1), RATE_LIMIT_MAX_WAIT].min
467
+ end
468
+
458
469
  def bounded_retry_delay
459
470
  exponent = [[@consecutive_failures - 1, 0].max, 30].min
460
471
  cap = [@retry_base_delay * (2**exponent), @retry_max_delay].min
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LogBrew
4
- VERSION = "0.1.17"
4
+ VERSION = "0.1.18"
5
5
  end
data/lib/logbrew.rb CHANGED
@@ -100,21 +100,31 @@ module LogBrew
100
100
  end
101
101
 
102
102
  class TransportResponse
103
- attr_reader :status_code, :attempts, :batches
103
+ attr_reader :status_code, :attempts, :batches, :retry_after, :code
104
104
 
105
- def initialize(status_code, attempts, batches = 1)
105
+ # For a 429, +retry_after+ is the Retry-After delay in seconds and +code+ is the
106
+ # JSON body's code string; either may be nil.
107
+ def initialize(status_code, attempts, batches = 1, retry_after: nil, code: nil)
106
108
  @status_code = status_code
107
109
  @attempts = attempts
108
110
  @batches = batches
111
+ @retry_after = retry_after
112
+ @code = code
113
+ end
114
+
115
+ # A 429 waits on its own when it has Retry-After or code "rate_limited", unless the usage limit was hit.
116
+ def timed_rate_limit?
117
+ status_code == 429 && code != "usage_limit_exceeded" && (!retry_after.nil? || code == "rate_limited")
109
118
  end
110
119
  end
111
120
 
112
121
  class DeliveryFailure < SdkError
113
- attr_reader :automatic_pause_reason
122
+ attr_reader :automatic_pause_reason, :automatic_rate_limit
114
123
 
115
- def initialize(code, message, automatic_retryable: false, automatic_pause_reason: nil)
124
+ def initialize(code, message, automatic_retryable: false, automatic_pause_reason: nil, automatic_rate_limit: nil)
116
125
  @automatic_retryable = automatic_retryable
117
126
  @automatic_pause_reason = automatic_pause_reason
127
+ @automatic_rate_limit = automatic_rate_limit
118
128
  super(code, message)
119
129
  end
120
130
 
@@ -148,7 +158,9 @@ module LogBrew
148
158
  raise response if response.is_a?(TransportError)
149
159
  raise response if response.is_a?(SdkError)
150
160
 
151
- status_code = response.is_a?(TransportResponse) ? response.status_code : response.to_i
161
+ return TransportResponse.new(response.status_code, 1, retry_after: response.retry_after, code: response.code) if response.is_a?(TransportResponse)
162
+
163
+ status_code = response.to_i
152
164
  TransportResponse.new(status_code.zero? ? 202 : status_code, 1)
153
165
  end
154
166
  end
@@ -156,6 +168,8 @@ module LogBrew
156
168
  class HttpTransport
157
169
  DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events"
158
170
  DEFAULT_TIMEOUT = 10
171
+ MAX_ERROR_BODY_BYTES = 4096
172
+ MAX_RATE_LIMIT_WAIT = 3600
159
173
 
160
174
  attr_reader :endpoint, :headers, :timeout, :http_client
161
175
 
@@ -176,18 +190,49 @@ module LogBrew
176
190
  @headers.each { |name, value| request[name] = value }
177
191
  request.body = body
178
192
 
179
- response = HttpClientTracing.suppress do
180
- @http_client ? @http_client.request(request) : request_with_default_client(request)
193
+ response, error_body = HttpClientTracing.suppress do
194
+ @http_client ? [@http_client.request(request), nil] : request_with_default_client(request)
181
195
  end
182
- TransportResponse.new(response.code.to_i, 1)
196
+ status_code = response.code.to_i
197
+ return TransportResponse.new(status_code, 1) unless status_code == 429
198
+
199
+ TransportResponse.new(
200
+ status_code,
201
+ 1,
202
+ retry_after: self.class.parse_retry_after(response.get_fields("retry-after")),
203
+ code: error_code(error_body || response.body)
204
+ )
183
205
  rescue TransportError
184
206
  raise
185
207
  rescue IOError, SystemCallError, SocketError, Timeout::Error, EOFError, Net::OpenTimeout, Net::ReadTimeout => error
186
208
  raise TransportError.network("http transport failed: #{error.message}")
187
209
  end
188
210
 
211
+ # Reads one Retry-After value as seconds: delta-seconds or an HTTP date, where a past date is 0.
212
+ # Missing, empty, repeated, or unreadable values return nil.
213
+ def self.parse_retry_after(values, now = Time.now)
214
+ return nil unless values.is_a?(Array) && values.length == 1
215
+
216
+ value = values.first.to_s.strip
217
+ return [value.to_i, MAX_RATE_LIMIT_WAIT].min if value.match?(/\A\d+\z/)
218
+
219
+ (Time.httpdate(value) - now).clamp(0, MAX_RATE_LIMIT_WAIT)
220
+ rescue ArgumentError
221
+ nil
222
+ end
223
+
189
224
  private
190
225
 
226
+ # Finds the JSON code of a 429 body of at most 4 KiB; the body is never kept.
227
+ def error_code(body)
228
+ return nil if body.nil? || body.bytesize > MAX_ERROR_BODY_BYTES
229
+
230
+ parsed = JSON.parse(body)
231
+ parsed.is_a?(Hash) && parsed["code"].is_a?(String) ? parsed["code"] : nil
232
+ rescue JSON::ParserError
233
+ nil
234
+ end
235
+
191
236
  def request_path
192
237
  path = @endpoint.path.empty? ? "/" : @endpoint.path
193
238
  return path if @endpoint.query.nil? || @endpoint.query.empty?
@@ -201,7 +246,26 @@ module LogBrew
201
246
  http.open_timeout = @timeout
202
247
  http.read_timeout = @timeout
203
248
  http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
204
- http.start { |client| client.request(request) }
249
+ http.start do |client|
250
+ catch(:logbrew_rate_limited) do
251
+ response = client.request(request) do |received|
252
+ throw :logbrew_rate_limited, [received, bounded_body(received)] if received.code == "429"
253
+ end
254
+ [response, nil]
255
+ end
256
+ end
257
+ end
258
+
259
+ # Leaves the request after at most 4 KiB plus one byte, so a larger 429 body is never read.
260
+ def bounded_body(response)
261
+ body = +""
262
+ catch(:logbrew_body_limit) do
263
+ response.read_body do |chunk|
264
+ body << chunk
265
+ throw :logbrew_body_limit if body.bytesize > MAX_ERROR_BODY_BYTES
266
+ end
267
+ end
268
+ body
205
269
  end
206
270
 
207
271
  def validate_endpoint(endpoint)
@@ -1213,6 +1277,15 @@ module LogBrew
1213
1277
  )
1214
1278
  end
1215
1279
 
1280
+ if response.respond_to?(:timed_rate_limit?) && response.timed_rate_limit?
1281
+ raise DeliveryFailure.new(
1282
+ "transport_error",
1283
+ "unexpected transport status 429",
1284
+ automatic_retryable: true,
1285
+ automatic_rate_limit: response.retry_after || :backoff
1286
+ )
1287
+ end
1288
+
1216
1289
  pause_reason = case response.status_code
1217
1290
  when 429 then "quota"
1218
1291
  when 400, 422 then "validation"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logbrew-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.17
4
+ version: 0.1.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - LogBrew
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-26 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Public LogBrew Ruby SDK with typed issue diagnostics, automatic Rails
14
14
  request/error capture, and standard-library delivery.