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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +103 -6
- data/lib/generators/logister/templates/logister.rb +8 -0
- data/lib/logister/active_job_reporter.rb +11 -5
- data/lib/logister/client.rb +165 -40
- data/lib/logister/configuration.rb +8 -2
- data/lib/logister/context_helpers.rb +7 -7
- data/lib/logister/context_store.rb +10 -0
- data/lib/logister/event_sanitizer.rb +210 -0
- data/lib/logister/middleware.rb +114 -21
- data/lib/logister/railtie.rb +8 -0
- data/lib/logister/redaction.rb +127 -0
- data/lib/logister/reporter.rb +28 -24
- data/lib/logister/request_subscriber.rb +3 -11
- data/lib/logister/sql_subscriber.rb +2 -2
- data/lib/logister/version.rb +1 -1
- data/lib/logister.rb +72 -25
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b53f55583642650e335b75f813f6ed58db64039e24117579e7448fcd1b4cff42
|
|
4
|
+
data.tar.gz: afc66cf2bf7a07d36cd12ae1fbc0c8339a5fe5c513919cd8fc49c79ac922af6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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: {
|
data/lib/logister/client.rb
CHANGED
|
@@ -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
|
-
|
|
93
|
+
event = encode_event(with_stable_uuid(payload))
|
|
94
|
+
return false unless event
|
|
59
95
|
|
|
60
|
-
return publish_sync(
|
|
96
|
+
return publish_sync(event) unless @configuration.async
|
|
61
97
|
|
|
62
98
|
ensure_worker_started
|
|
63
|
-
enqueue(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 <=
|
|
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
|
-
|
|
248
|
+
warn_internal("logister publish failed: #{e.class}")
|
|
198
249
|
false
|
|
199
250
|
end
|
|
200
251
|
end
|
|
201
252
|
|
|
202
|
-
def publish_batch_sync(
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 =
|
|
219
|
-
second_half_delivered =
|
|
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
|
-
|
|
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 <=
|
|
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
|
-
|
|
313
|
+
warn_internal("logister deployment publish failed: #{e.class}")
|
|
256
314
|
false
|
|
257
315
|
end
|
|
258
316
|
end
|
|
259
317
|
|
|
260
|
-
def send_request(
|
|
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 =
|
|
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(
|
|
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(
|
|
343
|
+
request['X-Logister-Batch-Id'] = batch_id(events)
|
|
285
344
|
|
|
286
|
-
body =
|
|
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 }
|
|
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
|
|
521
|
+
return RETRYABLE_STATUSES.include?(error.status) || error.status >= 500 if error.is_a?(RequestError)
|
|
398
522
|
|
|
399
|
-
|
|
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 =
|
|
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
|