logister-ruby 0.2.0 → 0.2.2

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: 2f3f9f67b976e92e535974c8a243b14f011088bca57e5ab3dc0385de6ba4c003
4
- data.tar.gz: d744d4996b00d27a2e51e54b5b91134f4c0ae049c76270a41c7ef93deec04189
3
+ metadata.gz: 0ffb0ff308c1a54f984e8c68088561c76a044e90766bee92a4ef23a51e0c5a78
4
+ data.tar.gz: fd0a658239131bec7061a722ed45044c0a2c7aba2125242b766837d0fd8d5dc8
5
5
  SHA512:
6
- metadata.gz: 9a8dbbbdde7778369654c287f9076a89efaadf5a3d03bdd5cb064546bac2b2b72bd53d641b424d3b4bc3cefecfce0bfd1b0a9c50714a8c58506b21c6765b8902
7
- data.tar.gz: 823a8c724d3aee752558e9936c23f924c774157c83a7b997885ba19a8dbf6aa835277506101f2eea341dde05923353f58127f6b94c0d8cbb6711823c5f43c946
6
+ metadata.gz: c89ea19d2cd68d4e7de0d85ff4b8c727fa0378d9a9e3b86e9a631fbb3e1a0df05d8cf7c9620636c9d7581011e53800cd57c9dffd7dabb9f83e52494c04d32c49
7
+ data.tar.gz: 42e48cd2049ab3adf2c496209dd71734891db9134f621920c988c138fb31bd3af99d1a72f39cc2b85f624f8f61ae586b873a036302fe0d29ca767d725cac4e9c
data/README.md CHANGED
@@ -23,6 +23,21 @@ Logister.configure do |config|
23
23
  config.environment = Rails.env
24
24
  config.service = Rails.application.class.module_parent_name.underscore
25
25
  config.release = ENV["RELEASE_SHA"]
26
+
27
+ # Optional richer context hooks
28
+ config.anonymize_ip = false
29
+ config.max_breadcrumbs = 40
30
+ config.max_dependencies = 20
31
+ config.capture_sql_breadcrumbs = true
32
+ config.sql_breadcrumb_min_duration_ms = 25.0
33
+
34
+ config.feature_flags_resolver = lambda do |request:, user:, **|
35
+ { new_checkout: user&.respond_to?(:beta?) && user.beta? }
36
+ end
37
+
38
+ config.dependency_resolver = lambda do |**|
39
+ [] # or return [{ name:, host:, method:, status:, durationMs:, kind: }]
40
+ end
26
41
  end
27
42
  ```
28
43
 
@@ -55,6 +70,7 @@ end
55
70
  ## Rails auto-reporting
56
71
 
57
72
  If Rails is present, the gem installs middleware that reports unhandled exceptions automatically.
73
+ It also attaches richer context (trace IDs, route/response/performance info, breadcrumbs, dependency calls, and user metadata when available).
58
74
 
59
75
  ## Database load metrics (ActiveRecord)
60
76
 
@@ -70,6 +86,37 @@ end
70
86
 
71
87
  This emits metric events with `message: "db.query"` and context fields such as `duration_ms`, `name`, `sql`, and `binds_count`.
72
88
 
89
+ ## Breadcrumbs and dependencies
90
+
91
+ You can add manual breadcrumbs and dependency calls that will be attached to captured errors:
92
+
93
+ ```ruby
94
+ Logister.add_breadcrumb(
95
+ category: "checkout",
96
+ message: "Starting payment authorization",
97
+ data: { order_id: 123 }
98
+ )
99
+
100
+ Logister.add_dependency(
101
+ name: "stripe.charge",
102
+ host: "api.stripe.com",
103
+ method: "POST",
104
+ status: 200,
105
+ duration_ms: 184.7,
106
+ kind: "http"
107
+ )
108
+ ```
109
+
110
+ The gem also captures request and SQL breadcrumbs automatically in Rails.
111
+
112
+ ## ActiveJob error context
113
+
114
+ Failed ActiveJob executions are auto-reported with `job` context:
115
+ - job class/id/queue/retries/schedule
116
+ - filtered job arguments (using `filter_parameters`)
117
+ - runtime/deployment metadata
118
+ - breadcrumbs/dependency calls collected during the job
119
+
73
120
  ## Manual reporting
74
121
 
75
122
  ```ruby
@@ -0,0 +1,83 @@
1
+ require_relative "context_helpers"
2
+ require_relative "context_store"
3
+
4
+ module Logister
5
+ module ActiveJobReporter
6
+ def self.install!
7
+ return unless defined?(ActiveJob::Base)
8
+ return if ActiveJob::Base < Logister::ActiveJobReporter::Instrumentation
9
+
10
+ ActiveJob::Base.include(Logister::ActiveJobReporter::Instrumentation)
11
+ end
12
+
13
+ module Instrumentation
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ around_perform do |job, block|
18
+ Logister::ContextStore.reset_request_scope!
19
+ Logister.add_breadcrumb(
20
+ category: "job",
21
+ message: "Starting #{job.class.name}",
22
+ data: { queue: job.queue_name, jobId: job.job_id }
23
+ )
24
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+
26
+ begin
27
+ block.call
28
+ rescue StandardError => error
29
+ Logister.report_error(
30
+ error,
31
+ context: Logister::ActiveJobReporter.build_job_error_context(job, started_at: started_at)
32
+ )
33
+ raise
34
+ ensure
35
+ Logister::ContextStore.reset_request_scope!
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ module_function
42
+
43
+ def build_job_error_context(job, started_at:)
44
+ Logister::ContextHelpers.compact_deep(
45
+ {
46
+ job: {
47
+ jobClass: job.class.name.to_s,
48
+ jobId: job.job_id.to_s.presence,
49
+ providerJobId: job.provider_job_id.to_s.presence,
50
+ queue: job.queue_name.to_s.presence,
51
+ priority: job.priority,
52
+ executions: job.executions,
53
+ exceptionExecutions: serialize_exception_executions(job),
54
+ locale: job.locale.to_s.presence,
55
+ timezone: (job.respond_to?(:timezone) ? job.timezone.to_s.presence : nil),
56
+ enqueuedAt: (job.respond_to?(:enqueued_at) ? time_to_iso8601(job.enqueued_at) : nil),
57
+ scheduledAt: time_to_iso8601(job.scheduled_at),
58
+ arguments: Logister::ContextHelpers.filtered_job_arguments(job)
59
+ }.compact,
60
+ breadcrumbs: Logister::ContextStore.breadcrumbs.presence,
61
+ dependencyCalls: Logister::ContextStore.dependencies.presence,
62
+ runtime: Logister::ContextHelpers.runtime_context[:runtime],
63
+ deployment: Logister::ContextHelpers.deployment_context[:deployment]
64
+ }
65
+ )
66
+ end
67
+
68
+ def serialize_exception_executions(job)
69
+ raw = job.respond_to?(:exception_executions) ? job.exception_executions : nil
70
+ return nil if raw.nil?
71
+
72
+ raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : raw
73
+ end
74
+
75
+ def time_to_iso8601(value)
76
+ return nil unless value.respond_to?(:iso8601)
77
+
78
+ value.iso8601
79
+ rescue StandardError
80
+ nil
81
+ end
82
+ end
83
+ end
@@ -1,15 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'net/http'
3
5
  require 'uri'
4
6
 
5
7
  module Logister
6
8
  class Client
9
+ CONTENT_TYPE = 'application/json'
10
+
7
11
  def initialize(configuration)
8
12
  @configuration = configuration
9
- @worker_mutex = Mutex.new
10
- @queue = SizedQueue.new(@configuration.queue_size)
11
- @worker = nil
12
- @running = false
13
+ @worker_mutex = Mutex.new
14
+ @queue = SizedQueue.new(@configuration.queue_size)
15
+ @worker = nil
16
+ @running = false
17
+
18
+ # Cache values that are static for the lifetime of this client so we
19
+ # don't allocate on every send_request call.
20
+ @uri = URI.parse(@configuration.endpoint).freeze
21
+ @use_ssl = @uri.scheme == 'https'
22
+ @auth_header = "Bearer #{@configuration.api_key}".freeze
13
23
  end
14
24
 
15
25
  def publish(payload)
@@ -24,9 +34,9 @@ module Logister
24
34
  def flush(timeout: 2)
25
35
  return true unless @configuration.async
26
36
 
27
- started_at = monotonic_now
28
- while @queue.length.positive?
29
- return false if monotonic_now - started_at > timeout
37
+ deadline = monotonic_now + timeout
38
+ until @queue.empty?
39
+ return false if monotonic_now > deadline
30
40
 
31
41
  sleep(0.01)
32
42
  end
@@ -44,6 +54,7 @@ module Logister
44
54
  nil
45
55
  end
46
56
  @worker&.join(1)
57
+ @worker = nil
47
58
  true
48
59
  end
49
60
 
@@ -58,18 +69,20 @@ module Logister
58
69
  end
59
70
 
60
71
  def ensure_worker_started
72
+ # Fast path — no lock needed if already running (GVL-safe on MRI).
61
73
  return if @running && @worker&.alive?
62
74
 
63
75
  @worker_mutex.synchronize do
64
76
  return if @running && @worker&.alive?
65
77
 
66
78
  @running = true
67
- @worker = Thread.new { run_worker }
79
+ @worker = Thread.new { run_worker }
80
+ @worker.name = 'logister-worker'
68
81
  end
69
82
  end
70
83
 
71
84
  def run_worker
72
- while @running
85
+ loop do
73
86
  payload = @queue.pop
74
87
  break if payload.nil?
75
88
 
@@ -77,6 +90,9 @@ module Logister
77
90
  end
78
91
  rescue StandardError => e
79
92
  @configuration.logger.warn("logister worker crashed: #{e.class} #{e.message}")
93
+ ensure
94
+ # Always clear running flag and attempt auto-restart after a crash so
95
+ # events enqueued after the crash are not silently dropped.
80
96
  @running = false
81
97
  end
82
98
 
@@ -97,16 +113,15 @@ module Logister
97
113
  end
98
114
 
99
115
  def send_request(payload)
100
- uri = URI.parse(@configuration.endpoint)
101
- request = Net::HTTP::Post.new(uri)
102
- request['Content-Type'] = 'application/json'
103
- request['Authorization'] = "Bearer #{@configuration.api_key}"
104
- request.body = { event: payload }.to_json
116
+ request = Net::HTTP::Post.new(@uri)
117
+ request['Content-Type'] = CONTENT_TYPE
118
+ request['Authorization'] = @auth_header
119
+ request.body = { event: payload }.to_json
105
120
 
106
121
  response = Net::HTTP.start(
107
- uri.host,
108
- uri.port,
109
- use_ssl: uri.scheme == 'https',
122
+ @uri.host,
123
+ @uri.port,
124
+ use_ssl: @use_ssl,
110
125
  open_timeout: @configuration.timeout_seconds,
111
126
  read_timeout: @configuration.timeout_seconds
112
127
  ) { |http| http.request(request) }
@@ -121,7 +136,7 @@ module Logister
121
136
  end
122
137
 
123
138
  def ready?
124
- @configuration.enabled && @configuration.api_key.to_s != ''
139
+ @configuration.enabled && !@configuration.api_key.to_s.empty?
125
140
  end
126
141
  end
127
142
  end
@@ -5,7 +5,10 @@ module Logister
5
5
  attr_accessor :api_key, :endpoint, :environment, :service, :release, :enabled, :timeout_seconds, :logger,
6
6
  :ignore_exceptions, :ignore_environments, :ignore_paths, :before_notify,
7
7
  :async, :queue_size, :max_retries, :retry_base_interval,
8
- :capture_db_metrics, :db_metric_min_duration_ms, :db_metric_sample_rate
8
+ :capture_db_metrics, :db_metric_min_duration_ms, :db_metric_sample_rate,
9
+ :feature_flags_resolver, :dependency_resolver, :anonymize_ip,
10
+ :max_breadcrumbs, :max_dependencies,
11
+ :capture_sql_breadcrumbs, :sql_breadcrumb_min_duration_ms
9
12
 
10
13
  def initialize
11
14
  @api_key = ENV['LOGISTER_API_KEY']
@@ -31,6 +34,14 @@ module Logister
31
34
  @capture_db_metrics = false
32
35
  @db_metric_min_duration_ms = 0.0
33
36
  @db_metric_sample_rate = 1.0
37
+
38
+ @feature_flags_resolver = nil
39
+ @dependency_resolver = nil
40
+ @anonymize_ip = false
41
+ @max_breadcrumbs = 40
42
+ @max_dependencies = 20
43
+ @capture_sql_breadcrumbs = true
44
+ @sql_breadcrumb_min_duration_ms = 25.0
34
45
  end
35
46
  end
36
47
  end
@@ -0,0 +1,262 @@
1
+ require "digest"
2
+ require "socket"
3
+ require "ipaddr"
4
+
5
+ module Logister
6
+ module ContextHelpers
7
+ FILTERED_VALUE = "[FILTERED]".freeze
8
+
9
+ module_function
10
+
11
+ def runtime_context
12
+ {
13
+ runtime: {
14
+ rubyVersion: RUBY_VERSION,
15
+ railsVersion: defined?(Rails) ? Rails.version : nil,
16
+ rackVersion: defined?(Rack) ? Rack.release : nil,
17
+ platform: RUBY_PLATFORM
18
+ }.compact
19
+ }
20
+ end
21
+
22
+ def deployment_context
23
+ config = Logister.configuration if Logister.respond_to?(:configuration)
24
+ environment = config&.respond_to?(:environment) ? config.environment.to_s.presence : nil
25
+ service = config&.respond_to?(:service) ? config.service.to_s.presence : nil
26
+ release = config&.respond_to?(:release) ? config.release.to_s.presence : nil
27
+
28
+ {
29
+ deployment: {
30
+ environment: environment || ENV["RAILS_ENV"].to_s.presence || ENV["RACK_ENV"].to_s.presence || "development",
31
+ service: service || ENV["LOGISTER_SERVICE"].to_s.presence || "ruby-app",
32
+ release: release || ENV["LOGISTER_RELEASE"].to_s.presence,
33
+ region: ENV["FLY_REGION"].to_s.presence || ENV["RAILS_REGION"].to_s.presence || ENV["AWS_REGION"].to_s.presence,
34
+ hostname: Socket.gethostname.to_s.presence,
35
+ processPid: Process.pid
36
+ }.compact
37
+ }
38
+ end
39
+
40
+ def trace_context(headers:, env:)
41
+ traceparent = header_value(headers, "Traceparent")
42
+ b3_trace_id = header_value(headers, "X-B3-Traceid")
43
+ b3_span_id = header_value(headers, "X-B3-Spanid")
44
+ datadog_trace_id = header_value(headers, "X-Datadog-Trace-Id")
45
+ datadog_parent_id = header_value(headers, "X-Datadog-Parent-Id")
46
+ amzn_trace_id = header_value(headers, "X-Amzn-Trace-Id")
47
+
48
+ parsed_trace_id, parsed_span_id, parsed_sampled = parse_traceparent(traceparent)
49
+
50
+ {
51
+ trace: {
52
+ traceId: parsed_trace_id || b3_trace_id || datadog_trace_id,
53
+ spanId: parsed_span_id || b3_span_id || datadog_parent_id,
54
+ sampled: parsed_sampled,
55
+ traceparent: traceparent,
56
+ requestId: env["action_dispatch.request_id"].to_s.presence,
57
+ amznTraceId: amzn_trace_id
58
+ }.compact
59
+ }
60
+ end
61
+
62
+ def resolve_feature_flags(request:, env:, user:)
63
+ resolver = configuration_value(:feature_flags_resolver)
64
+ return {} unless resolver.respond_to?(:call)
65
+
66
+ raw = call_resolver(resolver, request: request, env: env, user: user)
67
+ flags = normalize_flags_hash(raw)
68
+ return {} if flags.empty?
69
+
70
+ { featureFlags: flags }
71
+ rescue StandardError
72
+ {}
73
+ end
74
+
75
+ def resolve_dependency_context(request:, env:)
76
+ resolver = configuration_value(:dependency_resolver)
77
+ return {} unless resolver.respond_to?(:call)
78
+
79
+ raw = call_resolver(resolver, request: request, env: env)
80
+ list = normalize_dependency_list(raw)
81
+ return {} if list.empty?
82
+
83
+ { dependencyCalls: list }
84
+ rescue StandardError
85
+ {}
86
+ end
87
+
88
+ def anonymize_ip(ip)
89
+ return nil if ip.to_s.strip.empty?
90
+ return ip.to_s unless configuration_value(:anonymize_ip, false)
91
+
92
+ parsed = IPAddr.new(ip.to_s)
93
+ if parsed.ipv4?
94
+ segments = ip.to_s.split(".")
95
+ return ip.to_s if segments.size != 4
96
+
97
+ "#{segments[0]}.#{segments[1]}.#{segments[2]}.0"
98
+ else
99
+ "#{parsed.mask(64).to_s}/64"
100
+ end
101
+ rescue StandardError
102
+ ip.to_s
103
+ end
104
+
105
+ def user_context_for(user)
106
+ return {} unless user
107
+
108
+ {
109
+ user: {
110
+ id: safe_call(user, :id).to_s.presence,
111
+ class: user.class.name.to_s.presence,
112
+ email_hash: hashed_email(user),
113
+ role: safe_call(user, :role).to_s.presence,
114
+ account_id: safe_call(user, :account_id).to_s.presence || safe_call(user, :tenant_id).to_s.presence
115
+ }.compact
116
+ }
117
+ end
118
+
119
+ def filtered_job_arguments(job)
120
+ arguments = Array(job.arguments)
121
+ return arguments if arguments.empty?
122
+
123
+ filter = ActiveSupport::ParameterFilter.new(
124
+ Array(Rails.application.config.filter_parameters)
125
+ )
126
+ arguments.map { |argument| filter_argument(argument, filter) }
127
+ rescue StandardError
128
+ arguments
129
+ end
130
+
131
+ def safe_call(object, method_name)
132
+ return nil unless object.respond_to?(method_name)
133
+
134
+ object.public_send(method_name)
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def hash_value(value)
140
+ return nil if value.to_s.strip.empty?
141
+
142
+ Digest::SHA256.hexdigest(value.to_s.strip.downcase)
143
+ end
144
+
145
+ def compact_deep(value)
146
+ case value
147
+ when Hash
148
+ value.each_with_object({}) do |(key, nested), acc|
149
+ compacted = compact_deep(nested)
150
+ next if blank_value?(compacted)
151
+
152
+ acc[key] = compacted
153
+ end
154
+ when Array
155
+ value.map { |item| compact_deep(item) }.reject { |item| blank_value?(item) }
156
+ else
157
+ value
158
+ end
159
+ end
160
+
161
+ def blank_value?(value)
162
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
163
+ end
164
+
165
+ def hashed_email(user)
166
+ email = safe_call(user, :email)
167
+ hash_value(email)
168
+ end
169
+ private_class_method :hashed_email
170
+
171
+ def filter_argument(argument, filter)
172
+ case argument
173
+ when Hash
174
+ filter.filter(argument)
175
+ when Array
176
+ argument.map { |nested| filter_argument(nested, filter) }
177
+ else
178
+ argument
179
+ end
180
+ end
181
+ private_class_method :filter_argument
182
+
183
+ def header_value(headers, key)
184
+ return nil unless headers.is_a?(Hash)
185
+
186
+ headers[key].presence || headers[key.downcase].presence || headers[key.upcase].presence
187
+ end
188
+ private_class_method :header_value
189
+
190
+ def parse_traceparent(traceparent)
191
+ return [ nil, nil, nil ] if traceparent.to_s.empty?
192
+
193
+ parts = traceparent.to_s.split("-")
194
+ return [ nil, nil, nil ] unless parts.size == 4
195
+
196
+ trace_id = parts[1].to_s
197
+ span_id = parts[2].to_s
198
+ flags = parts[3].to_s
199
+ sampled = flags.end_with?("01")
200
+
201
+ [ trace_id.presence, span_id.presence, sampled ]
202
+ rescue StandardError
203
+ [ nil, nil, nil ]
204
+ end
205
+ private_class_method :parse_traceparent
206
+
207
+ def normalize_flags_hash(raw)
208
+ case raw
209
+ when Hash
210
+ raw.each_with_object({}) do |(key, value), acc|
211
+ acc[key.to_s] = value
212
+ end
213
+ else
214
+ {}
215
+ end
216
+ end
217
+ private_class_method :normalize_flags_hash
218
+
219
+ def normalize_dependency_list(raw)
220
+ list = case raw
221
+ when Array then raw
222
+ when Hash then [ raw ]
223
+ else []
224
+ end
225
+
226
+ list.map do |item|
227
+ next unless item.is_a?(Hash)
228
+
229
+ {
230
+ name: item[:name] || item["name"],
231
+ host: item[:host] || item["host"],
232
+ method: item[:method] || item["method"],
233
+ status: item[:status] || item["status"],
234
+ durationMs: item[:durationMs] || item["durationMs"] || item[:duration_ms] || item["duration_ms"],
235
+ kind: item[:kind] || item["kind"],
236
+ error: item[:error] || item["error"]
237
+ }.compact
238
+ end.compact
239
+ end
240
+ private_class_method :normalize_dependency_list
241
+
242
+ def configuration_value(key, fallback = nil)
243
+ return fallback unless Logister.respond_to?(:configuration)
244
+
245
+ Logister.configuration.public_send(key)
246
+ rescue StandardError
247
+ fallback
248
+ end
249
+ private_class_method :configuration_value
250
+
251
+ def call_resolver(resolver, **kwargs)
252
+ if resolver.arity == 1
253
+ resolver.call(kwargs)
254
+ else
255
+ resolver.call(**kwargs)
256
+ end
257
+ rescue ArgumentError
258
+ resolver.call(kwargs[:request], kwargs[:env], kwargs[:user])
259
+ end
260
+ private_class_method :call_resolver
261
+ end
262
+ end
@@ -0,0 +1,134 @@
1
+ module Logister
2
+ module ContextStore
3
+ REQUEST_SCOPE_KEY = :__logister_request_scope
4
+ MAX_REQUEST_SUMMARIES = 200
5
+
6
+ module_function
7
+
8
+ def reset_request_scope!
9
+ Thread.current[REQUEST_SCOPE_KEY] = {
10
+ breadcrumbs: [],
11
+ dependencies: []
12
+ }
13
+ end
14
+
15
+ def add_breadcrumb(category:, message:, data: {}, level: "info", timestamp: Time.now.utc.iso8601)
16
+ scope = request_scope
17
+ breadcrumbs = scope[:breadcrumbs]
18
+ breadcrumbs << {
19
+ category: category.to_s,
20
+ message: message.to_s,
21
+ level: level.to_s,
22
+ timestamp: timestamp,
23
+ data: sanitize_hash(data)
24
+ }.compact
25
+ trim_collection!(breadcrumbs, max_breadcrumbs)
26
+ end
27
+
28
+ def breadcrumbs
29
+ request_scope[:breadcrumbs].dup
30
+ end
31
+
32
+ def add_dependency(name:, host: nil, method: nil, status: nil, duration_ms: nil, kind: nil, data: {})
33
+ scope = request_scope
34
+ deps = scope[:dependencies]
35
+ deps << sanitize_hash(
36
+ {
37
+ name: name.to_s.presence,
38
+ host: host.to_s.presence,
39
+ method: method.to_s.presence,
40
+ status: status,
41
+ durationMs: duration_ms && duration_ms.to_f.round(2),
42
+ kind: kind.to_s.presence,
43
+ data: sanitize_hash(data)
44
+ }.compact
45
+ )
46
+ trim_collection!(deps, max_dependencies)
47
+ end
48
+
49
+ def dependencies
50
+ request_scope[:dependencies].dup
51
+ end
52
+
53
+ def store_request_summary(request_id, summary)
54
+ return if request_id.to_s.empty?
55
+
56
+ cache = request_summaries
57
+ cache[request_id.to_s] = sanitize_hash(summary)
58
+ trim_hash!(cache, MAX_REQUEST_SUMMARIES)
59
+ end
60
+
61
+ def request_summary(request_id)
62
+ return nil if request_id.to_s.empty?
63
+
64
+ request_summaries[request_id.to_s]
65
+ end
66
+
67
+ def clear_request_summary(request_id)
68
+ request_summaries.delete(request_id.to_s)
69
+ end
70
+
71
+ def add_manual_dependency(**kwargs)
72
+ add_dependency(**kwargs)
73
+ end
74
+
75
+ def add_manual_breadcrumb(**kwargs)
76
+ add_breadcrumb(**kwargs)
77
+ end
78
+
79
+ def request_scope
80
+ Thread.current[REQUEST_SCOPE_KEY] ||= {
81
+ breadcrumbs: [],
82
+ dependencies: []
83
+ }
84
+ end
85
+ private_class_method :request_scope
86
+
87
+ def request_summaries
88
+ Thread.current[:__logister_request_summaries] ||= {}
89
+ end
90
+ private_class_method :request_summaries
91
+
92
+ def sanitize_hash(value)
93
+ return {} unless value.is_a?(Hash)
94
+
95
+ value.each_with_object({}) do |(key, nested), acc|
96
+ acc[key] = nested
97
+ end
98
+ end
99
+ private_class_method :sanitize_hash
100
+
101
+ def max_breadcrumbs
102
+ config_value(:max_breadcrumbs, 40).to_i.clamp(1, 500)
103
+ end
104
+ private_class_method :max_breadcrumbs
105
+
106
+ def max_dependencies
107
+ config_value(:max_dependencies, 20).to_i.clamp(1, 500)
108
+ end
109
+ private_class_method :max_dependencies
110
+
111
+ def trim_collection!(array, max_size)
112
+ overflow = array.size - max_size
113
+ array.shift(overflow) if overflow.positive?
114
+ end
115
+ private_class_method :trim_collection!
116
+
117
+ def trim_hash!(hash, max_size)
118
+ overflow = hash.size - max_size
119
+ return unless overflow.positive?
120
+
121
+ hash.keys.first(overflow).each { |key| hash.delete(key) }
122
+ end
123
+ private_class_method :trim_hash!
124
+
125
+ def config_value(key, fallback)
126
+ return fallback unless Logister.respond_to?(:configuration)
127
+
128
+ Logister.configuration.public_send(key)
129
+ rescue StandardError
130
+ fallback
131
+ end
132
+ private_class_method :config_value
133
+ end
134
+ end