logbrew-sdk 0.1.16 → 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 +26 -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
|
|
@@ -145,6 +145,24 @@ rm -f "$HOME/.logbrew/rails-service.ingest"
|
|
|
145
145
|
unset LOGBREW_PROJECT_ID
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
## Read Events Back
|
|
149
|
+
|
|
150
|
+
A successful send shows that LogBrew accepted the request. It does not show
|
|
151
|
+
that you can read the event. After your app sends one event, read it
|
|
152
|
+
back with a project read key:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
logbrew projects keys create "<project_id>" --kind read \
|
|
156
|
+
--ingest-key-file "$HOME/.logbrew/setup-check.read" --json
|
|
157
|
+
export LOGBREW_TOKEN="$(< "$HOME/.logbrew/setup-check.read")"
|
|
158
|
+
logbrew read logs --since 1h --json
|
|
159
|
+
logbrew issues --since 1h --json
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A read key reads 1 project and cannot send telemetry. Run `unset LOGBREW_TOKEN`
|
|
163
|
+
when you finish. [Read your events back](https://docs.logbrew.co/guides/verify-setup)
|
|
164
|
+
covers key kinds, empty results, and how to revoke the key.
|
|
165
|
+
|
|
148
166
|
## Usage
|
|
149
167
|
|
|
150
168
|
```ruby
|
|
@@ -542,7 +560,7 @@ source code, locals, arguments, or absolute paths. The raw Rails/Rack backtrace
|
|
|
542
560
|
option is separate and remains off by default. Run
|
|
543
561
|
`make -C examples run-issue-diagnostics` for a complete inspectable payload.
|
|
544
562
|
Rails, Rack, and Sidekiq issue capture reuse this same graph. See the shared
|
|
545
|
-
[exception-chain contract](../../
|
|
563
|
+
[exception-chain contract](../../README.md#exception-chains).
|
|
546
564
|
|
|
547
565
|
## Metrics
|
|
548
566
|
|
|
@@ -603,7 +621,7 @@ The helpers return normal `action` attributes, so they work with the existing qu
|
|
|
603
621
|
|
|
604
622
|
## Support Ticket Draft Diagnostics
|
|
605
623
|
|
|
606
|
-
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:
|
|
607
625
|
|
|
608
626
|
```ruby
|
|
609
627
|
draft = LogBrew::SupportTicketDraft.create(
|
|
@@ -624,7 +642,7 @@ draft = LogBrew::SupportTicketDraft.create(
|
|
|
624
642
|
puts JSON.generate(draft)
|
|
625
643
|
```
|
|
626
644
|
|
|
627
|
-
|
|
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.
|
|
628
646
|
|
|
629
647
|
## HTTP Delivery
|
|
630
648
|
|
|
@@ -706,7 +724,9 @@ client.shutdown
|
|
|
706
724
|
|
|
707
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.
|
|
708
726
|
|
|
709
|
-
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`.
|
|
710
730
|
|
|
711
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.
|
|
712
732
|
|
|
@@ -907,7 +927,7 @@ unhandled request exceptions are not captured twice.
|
|
|
907
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.
|
|
908
928
|
- `metric(...)` queues explicit, application-owned metric events with name, kind, value, unit, temporality, and low-cardinality metadata validation.
|
|
909
929
|
- `LogBrew::ProductTimeline` builds explicit, application-owned product action and network milestone timeline events with primitive metadata and query/hash-free routes.
|
|
910
|
-
- `LogBrew::SupportTicketDraft.create` builds
|
|
930
|
+
- `LogBrew::SupportTicketDraft.create` builds a redacted support-ticket body and sends nothing.
|
|
911
931
|
- `LogBrew::IssueDiagnostics` builds typed exception identity, mechanism/handled state, basename-only structured frames, and bounded ordered breadcrumbs without raw exception internals.
|
|
912
932
|
- `LogBrew::HttpTransport` sends queued batches through Ruby's standard `Net::HTTP` with configurable endpoint, headers, timeout, and app-owned HTTP client support.
|
|
913
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.
|