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 +4 -4
- data/README.md +8 -6
- data/lib/logbrew/automatic_delivery.rb +13 -2
- data/lib/logbrew/version.rb +1 -1
- data/lib/logbrew.rb +82 -9
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '050529417548746276ff875602c06b58d1356614727cab8713094be67d46bce9'
|
|
4
|
+
data.tar.gz: 9ce8b6b4a0873ca92907618d684a161f3a9151fcff19b19bb64efa133613263f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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](../../
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/logbrew/version.rb
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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-
|
|
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.
|