logister-ruby 0.5.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 831c1daef24ddf24e6b74e58b22abf58280881bc1b6db683819198e5f6808c03
4
- data.tar.gz: 1682771b953d6db5f323b33013c97618945bf3ff95b1c3117ae513c09e565d4c
3
+ metadata.gz: b53f55583642650e335b75f813f6ed58db64039e24117579e7448fcd1b4cff42
4
+ data.tar.gz: afc66cf2bf7a07d36cd12ae1fbc0c8339a5fe5c513919cd8fc49c79ac922af6f
5
5
  SHA512:
6
- metadata.gz: 5580094db13b735ec710752d10633d646f4975ebc48cc4720ea224af883e75c353f9afd724f4e7d41dddf32169498a4fea6f3c2bb1fb6bba0272cb72826ec094
7
- data.tar.gz: bae736ef8c2fff8e37b87fe10b21d5ce9162917c5ae24d63aa58c52396f0f0f8b74101a782840d86aba98eb90d1a6b4ebe73c39bc76b61dc6aaaeb58c5771856
6
+ metadata.gz: 63ea3535fcf6cb865c38a34cd0a7b71d59ad97e49c4dd66db5e7b698b04dfff400c321093c1f3530844620712a6fc171bac6a3e7b8fe2eaa6b4dda6295080528
7
+ data.tar.gz: 83464fee6f652b3045e371a8722e51730ff6eb7933ba010df2cbc83db4db42848b67276c7f5a80005ca0e1349e0bdc3ba42724cee123b0738ebbe267f17c62ec
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.6.0 - 2026-10-23
4
+
5
+ Security hardening. Several defaults change; review before upgrading.
6
+
7
+ - Added the shared Logister redaction spec. Values of sensitive keys become `"[REDACTED]"`; `Bearer`/`Basic` credentials, JWTs, `lgst_` keys, and sensitive `name=value` pairs are scrubbed from strings. It applies to the message and the whole context (URL, referer, headers, params, user, tags, breadcrumbs, dependencies, job arguments, span attributes, log fields, exception data), never to envelope identifiers. Add keys with `config.redact_keys`.
8
+ - **Changed default:** request headers are captured from an allowlist instead of a five-entry denylist, so `Proxy-Authorization`, `X-Auth-Token`, `X-Amz-Security-Token`, vendor signature headers, and similar are no longer sent. Add headers with `config.capture_headers`; sensitive names are still redacted.
9
+ - Redacted sensitive query values and userinfo passwords in the captured request URL and referer, and dropped fragments.
10
+ - `anonymize_ip` now also truncates `X-Forwarded-For` and `X-Real-Ip`.
11
+ - **Changed default:** `capture_sql_breadcrumbs` is now `false`. When enabled, and for `capture_db_metrics`, string and numeric SQL literals are replaced with `?`.
12
+ - `set_user` now stores the user in the request scope that the middleware and Active Job integration reset, fixing cross-request user attribution on reused threads. Added `Logister.set_user` and `Logister.clear_user`.
13
+ - The middleware no longer lets a context-building or reporting failure replace the application's exception, and it now works on plain Rack without Action Dispatch (parameters are omitted there).
14
+ - Telemetry never raises into the host: public `Logister.*` methods, the Active Job hook, and subscribers catch and log their own failures.
15
+ - Only transport failures (timeouts, `IOError`, `SocketError`, `OpenSSL::SSL::SSLError`, `Errno::*`) and HTTP 408/425/429/5xx are retried.
16
+ - Each event is sanitized and serialized individually before batching: invalid UTF-8 is scrubbed, non-finite floats become strings, and an event that cannot be serialized is dropped alone (`serialization_error`) instead of failing its whole batch after retries.
17
+ - Bounded every event: strings at 16 KB, collections at 100 items, depth at 10, 400 keys, and `config.max_event_bytes` (default 256 KB) overall, with a compact second pass before dropping (`event_too_large`).
18
+ - Synchronous delivery and deployment records use `config.sync_max_retries` (default 1) with waits capped at one second, instead of the background retry budget.
19
+ - The client remembers an unsupported batch endpoint instead of retrying it before every fallback.
20
+ - `Logister.configure` now shuts down the previous client, and a single `at_exit` hook flushes the current reporter.
21
+ - The client warns when an endpoint uses `http://` on a non-loopback host.
22
+ - Internal warnings log only error classes, never exception messages.
23
+
3
24
  ## v0.5.1 - 2026-09-26
4
25
 
5
26
  - Restore the enclosing request or job scope after inline and nested Active Job executions, including failures, preserving request tracing, breadcrumbs, and dependencies while keeping job telemetry isolated.
data/README.md CHANGED
@@ -122,10 +122,15 @@ Logister.configure do |config|
122
122
  config.anonymize_ip = false
123
123
  config.max_breadcrumbs = 40
124
124
  config.max_dependencies = 20
125
- config.capture_sql_breadcrumbs = true
125
+ config.capture_sql_breadcrumbs = false # default; literals are stripped when enabled
126
126
  config.sql_breadcrumb_min_duration_ms = 25.0
127
127
  config.capture_request_spans = true
128
128
 
129
+ # Redaction and payload bounds (see "Filtering and redaction")
130
+ config.redact_keys = [] # extra sensitive keys, e.g. ["tenant_ref"]
131
+ config.capture_headers = [] # extra request headers to capture
132
+ config.max_event_bytes = 262_144
133
+
129
134
  config.feature_flags_resolver = lambda do |request:, user:, **|
130
135
  { new_checkout: user&.respond_to?(:beta?) && user.beta? }
131
136
  end
@@ -136,7 +141,9 @@ Logister.configure do |config|
136
141
  end
137
142
  ```
138
143
 
139
- If you are using a self-hosted Logister install, point `config.endpoint` at your own Logister host instead of `logister.org`.
144
+ If you are using a self-hosted Logister install, point `config.endpoint` at your own Logister host instead of `logister.org`. Use HTTPS: the client logs a warning when an endpoint uses `http://` on a host other than `localhost` or a loopback address, because the API key and events would travel unencrypted.
145
+
146
+ Calling `Logister.configure` again replaces the reporter. The previous client drains what it already queued and stops, and one `at_exit` hook flushes whichever reporter is current when the process exits.
140
147
 
141
148
  Keep `LOGISTER_API_KEY` in your deployment secret store. Project API keys are write-only ingest credentials, but exposing one still lets another party submit unwanted telemetry to the project.
142
149
 
@@ -150,17 +157,36 @@ Logister.configure do |config|
150
157
  config.batch_interval = 0.05
151
158
  config.batch_compression = true
152
159
  config.max_retries = 3
160
+ config.sync_max_retries = 1
153
161
  config.retry_base_interval = 0.5
154
162
  config.max_retry_delay = 30.0
155
163
  config.retry_jitter = 0.2
156
164
  end
157
165
  ```
158
166
 
167
+ Only transport failures are retried: timeouts, `IOError`, `SocketError`,
168
+ `OpenSSL::SSL::SSLError`, `Errno::*`, and HTTP `408`, `425`, `429`, or `5xx`
169
+ responses. Any other error fails the attempt once.
170
+
171
+ With `config.async = false`, delivery runs on the caller's thread, so it uses
172
+ `sync_max_retries` (default `1`, never more than `max_retries`) and caps each
173
+ wait at one second. Deployment records are always sent this way.
174
+
175
+ Each event is sanitized and serialized on its own before it is batched. Invalid
176
+ UTF-8 is replaced, `NaN`/`Infinity` become strings, strings are truncated at
177
+ 16 KB, collections at 100 items, nesting at 10 levels, and each event at 400
178
+ keys, with `logister_truncated: true` added to the context when anything was
179
+ cut. An event larger than `max_event_bytes` (default 256 KB) after a second,
180
+ tighter pass is dropped and counted as unconfirmed with reason
181
+ `event_too_large`; an event that cannot be serialized is dropped with reason
182
+ `serialization_error`. Neither affects the other events in its batch.
183
+
159
184
  Asynchronous delivery assigns a UUID before enqueueing, combines queued events into
160
185
  gzip/NDJSON batches, and retries the same identifiers. This makes a whole-batch retry
161
186
  safe against a Logister server that supports the batch endpoint. Older servers are
162
187
  detected automatically and receive the same stable events through the single-event
163
- endpoint. Call `Logister.flush` before a short-lived process exits.
188
+ endpoint; the client remembers that answer for its lifetime instead of retrying
189
+ the batch endpoint every time. Call `Logister.flush` before a short-lived process exits.
164
190
 
165
191
  ### Delivery outcomes
166
192
 
@@ -186,6 +212,56 @@ Retryable responses honor `Retry-After` when present, cap any individual wait at
186
212
 
187
213
  ## Filtering and redaction
188
214
 
215
+ Every event is redacted in the client before it is serialized, using the default
216
+ spec shared by all Logister SDKs:
217
+
218
+ - **Sensitive keys.** A key is sensitive when, lowercased with every
219
+ non-alphanumeric character removed, it equals one of `password`, `passwd`,
220
+ `pwd`, `secret`, `token`, `apikey`, `authorization`, `proxyauthorization`,
221
+ `cookie`, `setcookie`, `sessiontoken`, `sessionkey`, `credential`,
222
+ `credentials`, `privatekey`, `clientsecret`, `accesstoken`, `refreshtoken`,
223
+ `idtoken`, `authtoken`, `xapikey`, `xauthtoken`, `xaccesstoken`,
224
+ `xcsrftoken`, `xxsrftoken`, `signature`, `ssn`, `creditcard`, `cardnumber`,
225
+ or `cvv`, or when it contains `token`, `secret`, `password`, `passwd`,
226
+ `apikey`, `authorization`, `credential`, or `cookie`. The whole value becomes
227
+ `"[REDACTED]"`.
228
+ - **Credential-shaped strings.** `Bearer <x>` and `Basic <x>` become
229
+ `Bearer [REDACTED]` / `Basic [REDACTED]`; JWT-shaped strings and `lgst_` keys
230
+ become `[REDACTED]`; a `name=value` pair with a sensitive name keeps its name.
231
+ - **URLs.** The captured request URL and referer keep scheme, host, and path;
232
+ sensitive query values and userinfo passwords become `[REDACTED]` and the
233
+ fragment is dropped.
234
+
235
+ Redaction covers the message and the whole context: request URL, referer,
236
+ headers, params, user context, tags, breadcrumbs, dependency calls, job
237
+ arguments (after Rails `filter_parameters`), span attributes, log fields, and
238
+ exception data. SDK envelope fields such as `uuid`, `event_type`, `trace_id`,
239
+ `span_id`, `parent_span_id`, `request_id`, and `session_id` are never redacted.
240
+
241
+ Add project-specific keys with `config.redact_keys`. Entries are normalized the
242
+ same way and must match exactly:
243
+
244
+ ```ruby
245
+ Logister.configure do |config|
246
+ config.redact_keys = ["tenant_ref", "X-Internal-Signature"]
247
+ end
248
+ ```
249
+
250
+ Request headers are captured from an allowlist: `Accept`, `Accept-Encoding`,
251
+ `Accept-Language`, `Cache-Control`, `Content-Length`, `Content-Type`, `Dnt`,
252
+ `Host`, `Origin`, `Referer`, `User-Agent`, `Via`, W3C, B3, Datadog, and AWS trace
253
+ headers, `X-Request-Id`, `X-Forwarded-For/Host/Proto/Port`, `X-Real-Ip`, and
254
+ `Sec-Fetch-*`/`Sec-Ch-Ua*` hints. Add headers with
255
+ `config.capture_headers = ["X-Tenant"]`; the sensitive-key rule still applies,
256
+ so a header such as `X-Auth-Token` is captured as `[REDACTED]`. With
257
+ `config.anonymize_ip = true`, the client IP and every address in
258
+ `X-Forwarded-For` and `X-Real-Ip` are truncated (`/24` for IPv4, `/64` for IPv6).
259
+
260
+ Telemetry never raises into your application. The public `Logister.*` methods,
261
+ the Rack middleware, the Active Job hook, and the SQL and request subscribers
262
+ catch their own failures, log the error class through `config.logger`, and
263
+ re-raise only the application's original exception.
264
+
189
265
  ```ruby
190
266
  Logister.configure do |config|
191
267
  config.ignore_environments = ["development", "test"]
@@ -211,7 +287,16 @@ This is especially important when a Rails application reports into a Logister pr
211
287
 
212
288
  ## Rails auto-reporting
213
289
 
214
- If Rails is present, the gem installs middleware that reports unhandled exceptions automatically. It attaches trace IDs, route and response data, performance context, breadcrumbs, dependency calls, and user metadata when available.
290
+ If Rails is present, the gem installs middleware that reports unhandled exceptions automatically. Plain Rack apps can add it themselves:
291
+
292
+ ```ruby
293
+ # config.ru
294
+ require "logister"
295
+ use Logister::Middleware
296
+ run MyApp
297
+ ```
298
+
299
+ Without Action Dispatch the middleware uses `Rack::Request` and omits request parameters, because there is no host parameter filter; the redacted URL still records the query string. It attaches trace IDs, route and response data, performance context, breadcrumbs, dependency calls, and user metadata when available.
215
300
 
216
301
  Set `config.capture_request_spans = true` to emit root `server` spans for request-load waterfall charts while keeping the existing transaction events. Manual `Logister.report_error` calls use the same enrichment path, including runtime, deployment, breadcrumb, dependency, user, and nested-exception context.
217
302
 
@@ -227,7 +312,7 @@ Logister.configure do |config|
227
312
  end
228
313
  ```
229
314
 
230
- This emits metric events with `message: "db.query"` and context fields such as `duration_ms`, `name`, `sql`, and `binds_count`.
315
+ This emits metric events with `message: "db.query"` and context fields such as `duration_ms`, `name`, `sql`, and `binds_count`. String and numeric literals are replaced with `?` in the captured `sql`, so inlined values such as password hashes or reset tokens are not sent; bind placeholders like `$1` and quoted identifiers are kept.
231
316
 
232
317
  ## Breadcrumbs and dependencies
233
318
 
@@ -250,7 +335,7 @@ Logister.add_dependency(
250
335
  )
251
336
  ```
252
337
 
253
- The gem also captures request and SQL breadcrumbs automatically in Rails.
338
+ The gem also captures request breadcrumbs automatically in Rails. SQL breadcrumbs are off by default because adapters without prepared statements inline literal values; set `config.capture_sql_breadcrumbs = true` to record queries slower than `sql_breadcrumb_min_duration_ms`, with string and numeric literals replaced by `?`.
254
339
 
255
340
  ## ActiveJob error context
256
341
 
@@ -262,6 +347,18 @@ Failed ActiveJob executions are auto-reported with `job` context:
262
347
 
263
348
  ## Manual reporting
264
349
 
350
+ Attach the signed-in user to errors reported during the current request or job:
351
+
352
+ ```ruby
353
+ Logister.set_user(id: current_user.id, email: current_user.email, name: current_user.name)
354
+ Logister.clear_user
355
+ ```
356
+
357
+ The user is stored in the request scope, which the Rack middleware and the
358
+ Active Job integration reset when each request or job ends, so identity never
359
+ carries over to the next request on a reused Puma or Sidekiq thread. In other
360
+ long-lived threads, call `Logister.clear_user` when the unit of work ends.
361
+
265
362
  ```ruby
266
363
  Logister.report_error(StandardError.new("Something failed"), tags: { area: "checkout" })
267
364
 
@@ -22,10 +22,18 @@ Logister.configure do |config|
22
22
  config.ignore_exceptions = []
23
23
  config.ignore_paths = []
24
24
 
25
+ # Values of sensitive keys (password, token, secret, authorization, cookie,
26
+ # and more) are always replaced with "[REDACTED]". Add project-specific keys.
27
+ config.redact_keys = []
28
+ # Request headers are captured from an allowlist; add extra header names here.
29
+ config.capture_headers = []
30
+
25
31
  # Optional ActiveRecord SQL instrumentation.
26
32
  config.capture_db_metrics = false
27
33
  config.db_metric_min_duration_ms = 10.0
28
34
  config.db_metric_sample_rate = 1.0
35
+ # SQL breadcrumbs are off by default; literals are stripped when enabled.
36
+ config.capture_sql_breadcrumbs = false
29
37
 
30
38
  config.before_notify = lambda do |payload|
31
39
  payload
@@ -26,10 +26,7 @@ module Logister
26
26
  begin
27
27
  block.call
28
28
  rescue StandardError => error
29
- Logister.report_error(
30
- error,
31
- context: Logister::ActiveJobReporter.build_job_error_context(job, started_at: started_at)
32
- )
29
+ Logister::ActiveJobReporter.report_job_error(job, error, started_at: started_at)
33
30
  raise
34
31
  end
35
32
  end
@@ -39,7 +36,16 @@ module Logister
39
36
 
40
37
  module_function
41
38
 
42
- def build_job_error_context(job, started_at:)
39
+ # Context building and reporting run in their own rescue so a telemetry
40
+ # failure can never replace the job's exception.
41
+ def report_job_error(job, error, started_at:)
42
+ Logister.report_error(error, context: build_job_error_context(job, started_at: started_at))
43
+ rescue StandardError => e
44
+ Logister.log_internal_error('job error report', e)
45
+ nil
46
+ end
47
+
48
+ def build_job_error_context(job, started_at: nil) # rubocop:disable Lint/UnusedMethodArgument
43
49
  Logister::ContextHelpers.compact_deep(
44
50
  {
45
51
  job: {
@@ -7,15 +7,47 @@ require 'securerandom'
7
7
  require 'time'
8
8
  require 'uri'
9
9
  require 'zlib'
10
+ require 'ipaddr'
11
+ begin
12
+ require 'openssl'
13
+ rescue LoadError
14
+ nil
15
+ end
16
+ require_relative 'event_sanitizer'
17
+ require_relative 'redaction'
10
18
 
11
19
  module Logister
12
20
  class Client
13
21
  CONTENT_TYPE = 'application/json'
14
22
  BATCH_CONTENT_TYPE = 'application/x-ndjson'
15
23
  UNSUPPORTED_BATCH_STATUSES = %w[404 405 415 501].freeze
24
+ RETRYABLE_STATUSES = [408, 425, 429].freeze
25
+ SYNC_MAX_RETRY_DELAY = 1.0
26
+ RETRYABLE_ERRORS = [
27
+ Timeout::Error, IOError, SocketError, SystemCallError,
28
+ (OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError))
29
+ ].compact.freeze
16
30
 
17
31
  class UnsupportedBatchEndpoint < StandardError; end
18
32
 
33
+ # One event serialized on its own before batching. A payload that cannot be
34
+ # encoded is dropped alone instead of failing every event batched with it.
35
+ class Event
36
+ attr_reader :uuid, :payload, :json
37
+
38
+ def initialize(uuid:, payload:, json:)
39
+ @uuid = uuid
40
+ @payload = payload
41
+ @json = json
42
+ freeze
43
+ end
44
+
45
+ def [](key) = payload[key]
46
+ def fetch(...) = payload.fetch(...)
47
+ def key?(key) = payload.key?(key)
48
+ def to_h = payload
49
+ end
50
+
19
51
  class RequestError < StandardError
20
52
  attr_reader :status, :retry_after
21
53
 
@@ -49,24 +81,34 @@ module Logister
49
81
  @batch_use_ssl = @batch_uri.scheme == 'https'
50
82
  @deployment_use_ssl = @deployment_uri.scheme == 'https'
51
83
  @auth_header = "Bearer #{@configuration.api_key}".freeze
84
+ @redact_keys = Redaction.normalize_keys(@configuration.redact_keys)
85
+ @batch_unsupported = false
86
+ warn_insecure_endpoints
52
87
  end
53
88
 
54
89
  def publish(payload)
55
90
  return false if ReportingScope.suppressed?
56
91
  return false unless ready?
57
92
 
58
- payload = with_stable_uuid(payload)
93
+ event = encode_event(with_stable_uuid(payload))
94
+ return false unless event
59
95
 
60
- return publish_sync(payload) unless @configuration.async
96
+ return publish_sync(event) unless @configuration.async
61
97
 
62
98
  ensure_worker_started
63
- enqueue(payload)
99
+ enqueue(event)
100
+ rescue StandardError => e
101
+ warn_internal("logister publish failed: #{e.class}")
102
+ false
64
103
  end
65
104
 
66
105
  def publish_deployment(payload)
67
106
  return false unless ready?
68
107
 
69
108
  publish_deployment_sync(payload)
109
+ rescue StandardError => e
110
+ warn_internal("logister deployment publish failed: #{e.class}")
111
+ false
70
112
  end
71
113
 
72
114
  def flush(timeout: 2)
@@ -122,7 +164,7 @@ module Logister
122
164
  rescue ThreadError
123
165
  complete_pending(1)
124
166
  record_delivery(:queue_full_events, 1, reason: 'queue_full')
125
- @configuration.logger.warn('logister queue full; dropping event')
167
+ warn_internal('logister queue full; dropping event')
126
168
  false
127
169
  end
128
170
 
@@ -168,18 +210,27 @@ module Logister
168
210
  break if stop_after_batch || (@stopping && @queue.empty?)
169
211
  end
170
212
  rescue StandardError => e
171
- @configuration.logger.warn("logister worker crashed: #{e.class} #{e.message}")
213
+ warn_internal("logister worker crashed: #{e.class}")
172
214
  ensure
173
215
  # Always clear running flag and attempt auto-restart after a crash so
174
216
  # events enqueued after the crash are not silently dropped.
175
217
  @running = false
176
218
  end
177
219
 
178
- def publish_sync(payload)
220
+ # Synchronous mode delivers on the caller's thread, so it uses a small retry
221
+ # cap and short waits instead of the background worker's limits.
222
+ def publish_sync(event)
223
+ publish_single(event, max_retries: sync_max_retries, max_delay: SYNC_MAX_RETRY_DELAY)
224
+ end
225
+
226
+ def publish_single(event, max_retries: @configuration.max_retries.to_i, max_delay: nil)
227
+ event = coerce_event(event)
228
+ return false unless event
229
+
179
230
  attempts = 0
180
231
  begin
181
232
  attempts += 1
182
- delivered = send_request(payload)
233
+ delivered = send_request(event)
183
234
  unless delivered
184
235
  record_delivery(:unconfirmed_events, 1, reason: 'no_acknowledgment')
185
236
  return false
@@ -187,19 +238,29 @@ module Logister
187
238
  record_delivery(:acknowledged_events, 1)
188
239
  true
189
240
  rescue StandardError => e
190
- if attempts <= @configuration.max_retries && retryable_error?(e)
241
+ if attempts <= max_retries && retryable_error?(e)
191
242
  record_delivery(:retry_attempts, 1, reason: failure_reason(e))
192
- sleep(retry_delay(e, attempts))
243
+ sleep(retry_delay(e, attempts, max_delay: max_delay))
193
244
  retry
194
245
  end
195
246
 
196
247
  record_delivery(:unconfirmed_events, 1, reason: failure_reason(e))
197
- @configuration.logger.warn("logister publish failed: #{e.class}")
248
+ warn_internal("logister publish failed: #{e.class}")
198
249
  false
199
250
  end
200
251
  end
201
252
 
202
- def publish_batch_sync(payloads)
253
+ def publish_batch_sync(items)
254
+ payloads = items.filter_map { |item| coerce_event(item) }
255
+ all_encoded = payloads.length == items.length
256
+ return all_encoded if payloads.empty?
257
+
258
+ deliver_batch(payloads) && all_encoded
259
+ end
260
+
261
+ def deliver_batch(payloads)
262
+ return publish_each(payloads) if @batch_unsupported
263
+
203
264
  attempts = 0
204
265
  begin
205
266
  attempts += 1
@@ -211,57 +272,54 @@ module Logister
211
272
  record_delivery(:acknowledged_events, payloads.length)
212
273
  true
213
274
  rescue UnsupportedBatchEndpoint
214
- payloads.map { |payload| publish_sync(payload) }.all?
215
- rescue RequestError => e
216
- if e.status == 413 && payloads.length > 1
275
+ # Older servers keep this answer for the life of the client; stop paying
276
+ # for a failed batch request before every fallback.
277
+ @batch_unsupported = true
278
+ publish_each(payloads)
279
+ rescue StandardError => e
280
+ if e.is_a?(RequestError) && e.status == 413 && payloads.length > 1
217
281
  middle = (payloads.length / 2.0).ceil
218
- first_half_delivered = publish_batch_sync(payloads.first(middle))
219
- second_half_delivered = publish_batch_sync(payloads.drop(middle))
282
+ first_half_delivered = deliver_batch(payloads.first(middle))
283
+ second_half_delivered = deliver_batch(payloads.drop(middle))
220
284
  return first_half_delivered && second_half_delivered
221
285
  end
222
- if attempts <= @configuration.max_retries && retryable_error?(e)
286
+ if attempts <= @configuration.max_retries.to_i && retryable_error?(e)
223
287
  record_delivery(:retry_attempts, 1, reason: failure_reason(e))
224
288
  sleep(retry_delay(e, attempts))
225
289
  retry
226
290
  end
227
291
 
228
292
  record_delivery(:unconfirmed_events, payloads.length, reason: failure_reason(e))
229
- @configuration.logger.warn("logister batch publish failed: #{e.class}")
230
- false
231
- rescue StandardError => e
232
- if attempts <= @configuration.max_retries && retryable_error?(e)
233
- record_delivery(:retry_attempts, 1, reason: failure_reason(e))
234
- sleep(retry_delay(e, attempts))
235
- retry
236
- end
237
-
238
- record_delivery(:unconfirmed_events, payloads.length, reason: failure_reason(e))
239
- @configuration.logger.warn("logister batch publish failed: #{e.class}")
293
+ warn_internal("logister batch publish failed: #{e.class}")
240
294
  false
241
295
  end
242
296
  end
243
297
 
298
+ def publish_each(events)
299
+ events.map { |event| publish_single(event) }.all?
300
+ end
301
+
244
302
  def publish_deployment_sync(payload)
245
303
  attempts = 0
246
304
  begin
247
305
  attempts += 1
248
306
  send_deployment_request(payload)
249
307
  rescue StandardError => e
250
- if attempts <= @configuration.max_retries && retryable_error?(e)
251
- sleep(retry_delay(e, attempts))
308
+ if attempts <= sync_max_retries && retryable_error?(e)
309
+ sleep(retry_delay(e, attempts, max_delay: SYNC_MAX_RETRY_DELAY))
252
310
  retry
253
311
  end
254
312
 
255
- @configuration.logger.warn("logister deployment publish failed: #{e.class} #{e.message}")
313
+ warn_internal("logister deployment publish failed: #{e.class}")
256
314
  false
257
315
  end
258
316
  end
259
317
 
260
- def send_request(payload)
318
+ def send_request(event)
261
319
  request = Net::HTTP::Post.new(@uri)
262
320
  request['Content-Type'] = CONTENT_TYPE
263
321
  request['Authorization'] = @auth_header
264
- request.body = { event: payload }.to_json
322
+ request.body = coerce_event!(event).json
265
323
 
266
324
  response = Net::HTTP.start(
267
325
  @uri.host,
@@ -277,13 +335,14 @@ module Logister
277
335
  raise request_error(response)
278
336
  end
279
337
 
280
- def send_batch_request(payloads)
338
+ def send_batch_request(events)
339
+ events = events.map { |event| coerce_event!(event) }
281
340
  request = Net::HTTP::Post.new(@batch_uri)
282
341
  request['Content-Type'] = BATCH_CONTENT_TYPE
283
342
  request['Authorization'] = @auth_header
284
- request['X-Logister-Batch-Id'] = batch_id(payloads)
343
+ request['X-Logister-Batch-Id'] = batch_id(events)
285
344
 
286
- body = payloads.map { |payload| { event: payload }.to_json }.join("\n") << "\n"
345
+ body = events.map(&:json).join("\n") << "\n"
287
346
  if @configuration.batch_compression
288
347
  request['Content-Encoding'] = 'gzip'
289
348
  body = Zlib.gzip(body)
@@ -309,7 +368,7 @@ module Logister
309
368
  request = Net::HTTP::Post.new(@deployment_uri)
310
369
  request['Content-Type'] = CONTENT_TYPE
311
370
  request['Authorization'] = @auth_header
312
- request.body = { deployment: payload }.to_json
371
+ request.body = JSON.generate({ deployment: EventSanitizer.call(payload, extra_keys: @redact_keys) })
313
372
 
314
373
  response = Net::HTTP.start(
315
374
  @deployment_uri.host,
@@ -369,11 +428,74 @@ module Logister
369
428
 
370
429
  def batch_id(payloads)
371
430
  identifiers = payloads.map do |payload|
431
+ next payload.uuid if payload.is_a?(Event)
432
+
372
433
  payload[:uuid] || payload['uuid'] || payload[:event_id] || payload['event_id']
373
434
  end
374
435
  Digest::SHA256.hexdigest(identifiers.join("\n"))
375
436
  end
376
437
 
438
+ # Sanitizes, redacts, and serializes one event. Oversized events are retried
439
+ # with compact limits; an event that still cannot be encoded is dropped and
440
+ # counted without affecting any other event.
441
+ def encode_event(payload)
442
+ uuid = payload[:uuid] || payload['uuid']
443
+ [EventSanitizer::DEFAULT_LIMITS, EventSanitizer::COMPACT_LIMITS].each do |limits|
444
+ sanitized = EventSanitizer.call(payload, extra_keys: @redact_keys, limits: limits)
445
+ json = JSON.generate({ event: sanitized })
446
+ return Event.new(uuid: uuid.to_s, payload: sanitized, json: json) if json.bytesize <= max_event_bytes
447
+ end
448
+
449
+ record_delivery(:unconfirmed_events, 1, reason: 'event_too_large')
450
+ warn_internal('logister dropped an event larger than max_event_bytes')
451
+ nil
452
+ rescue StandardError => e
453
+ record_delivery(:unconfirmed_events, 1, reason: 'serialization_error')
454
+ warn_internal("logister could not serialize an event: #{e.class}")
455
+ nil
456
+ end
457
+
458
+ def coerce_event(item)
459
+ return item if item.is_a?(Event)
460
+
461
+ encode_event(with_stable_uuid(item))
462
+ end
463
+
464
+ def coerce_event!(item)
465
+ coerce_event(item) || raise(ArgumentError, 'event could not be encoded')
466
+ end
467
+
468
+ def max_event_bytes
469
+ [@configuration.max_event_bytes.to_i, 1024].max
470
+ end
471
+
472
+ def sync_max_retries
473
+ [[@configuration.sync_max_retries.to_i, 0].max, @configuration.max_retries.to_i].min
474
+ end
475
+
476
+ def warn_internal(message)
477
+ @configuration.logger&.warn(message)
478
+ rescue StandardError
479
+ nil
480
+ end
481
+
482
+ def warn_insecure_endpoints
483
+ [@uri, @batch_uri, @deployment_uri].uniq { |uri| [uri.host, uri.port] }.each do |uri|
484
+ next unless uri.scheme == 'http' && !loopback_host?(uri.host)
485
+
486
+ warn_internal("logister endpoint #{uri.scheme}://#{uri.host} is not HTTPS; the API key and events are sent unencrypted")
487
+ end
488
+ end
489
+
490
+ def loopback_host?(host)
491
+ name = host.to_s.downcase.delete_prefix('[').delete_suffix(']')
492
+ return true if name == 'localhost' || name.end_with?('.localhost')
493
+
494
+ IPAddr.new(name).loopback?
495
+ rescue ArgumentError
496
+ false
497
+ end
498
+
377
499
  def batch_size
378
500
  [@configuration.batch_size.to_i, 1].max
379
501
  end
@@ -393,10 +515,12 @@ module Logister
393
515
  end
394
516
  end
395
517
 
518
+ # Only transport failures and transient HTTP statuses are retried; a bug or
519
+ # an unencodable payload fails once instead of being repeated.
396
520
  def retryable_error?(error)
397
- return true unless error.is_a?(RequestError)
521
+ return RETRYABLE_STATUSES.include?(error.status) || error.status >= 500 if error.is_a?(RequestError)
398
522
 
399
- [408, 425, 429].include?(error.status) || error.status >= 500
523
+ RETRYABLE_ERRORS.any? { |klass| error.is_a?(klass) }
400
524
  end
401
525
 
402
526
  def request_error(response)
@@ -415,8 +539,9 @@ module Logister
415
539
  nil
416
540
  end
417
541
 
418
- def retry_delay(error, attempt)
542
+ def retry_delay(error, attempt, max_delay: nil)
419
543
  configured_cap = [@configuration.max_retry_delay.to_f, 0.0].max
544
+ configured_cap = [configured_cap, max_delay].min if max_delay
420
545
  base_delay = if error.is_a?(RequestError) && !error.retry_after.nil?
421
546
  error.retry_after.to_f
422
547
  else
@@ -10,7 +10,8 @@ module Logister
10
10
  :capture_db_metrics, :db_metric_min_duration_ms, :db_metric_sample_rate,
11
11
  :feature_flags_resolver, :dependency_resolver, :anonymize_ip,
12
12
  :max_breadcrumbs, :max_dependencies, :capture_request_spans,
13
- :capture_sql_breadcrumbs, :sql_breadcrumb_min_duration_ms, :delivery_observer
13
+ :capture_sql_breadcrumbs, :sql_breadcrumb_min_duration_ms, :delivery_observer,
14
+ :redact_keys, :capture_headers, :max_event_bytes, :sync_max_retries
14
15
  attr_writer :deployment_endpoint, :batch_endpoint
15
16
 
16
17
  def initialize
@@ -37,6 +38,7 @@ module Logister
37
38
  @async = true
38
39
  @queue_size = 1000
39
40
  @max_retries = 3
41
+ @sync_max_retries = 1
40
42
  @retry_base_interval = 0.5
41
43
  @max_retry_delay = 30.0
42
44
  @retry_jitter = 0.2
@@ -54,8 +56,12 @@ module Logister
54
56
  @max_breadcrumbs = 40
55
57
  @max_dependencies = 20
56
58
  @capture_request_spans = false
57
- @capture_sql_breadcrumbs = true
59
+ @capture_sql_breadcrumbs = false
58
60
  @sql_breadcrumb_min_duration_ms = 25.0
61
+
62
+ @redact_keys = []
63
+ @capture_headers = []
64
+ @max_event_bytes = 256 * 1024
59
65
  end
60
66
 
61
67
  def deployment_endpoint