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 +4 -4
- data/README.md +47 -0
- data/lib/logister/active_job_reporter.rb +83 -0
- data/lib/logister/client.rb +33 -18
- data/lib/logister/configuration.rb +12 -1
- data/lib/logister/context_helpers.rb +262 -0
- data/lib/logister/context_store.rb +134 -0
- data/lib/logister/middleware.rb +167 -39
- data/lib/logister/railtie.rb +18 -0
- data/lib/logister/reporter.rb +83 -38
- data/lib/logister/request_subscriber.rb +104 -0
- data/lib/logister/sql_subscriber.rb +31 -15
- data/lib/logister/version.rb +3 -1
- data/lib/logister.rb +24 -8
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ffb0ff308c1a54f984e8c68088561c76a044e90766bee92a4ef23a51e0c5a78
|
|
4
|
+
data.tar.gz: fd0a658239131bec7061a722ed45044c0a2c7aba2125242b766837d0fd8d5dc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/logister/client.rb
CHANGED
|
@@ -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
|
|
10
|
-
@queue
|
|
11
|
-
@worker
|
|
12
|
-
@running
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
return false if monotonic_now
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
request
|
|
102
|
-
request['
|
|
103
|
-
request
|
|
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:
|
|
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 &&
|
|
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
|