logister-ruby 0.2.1 → 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: d6f55dcaf8248136d818bf775ec1735e6b5340a83b4226eec28540679c3c2725
4
- data.tar.gz: 773b83846639292bbdb935a333f3384f6d3b737c1d3933044a7d6a8c59884d84
3
+ metadata.gz: 0ffb0ff308c1a54f984e8c68088561c76a044e90766bee92a4ef23a51e0c5a78
4
+ data.tar.gz: fd0a658239131bec7061a722ed45044c0a2c7aba2125242b766837d0fd8d5dc8
5
5
  SHA512:
6
- metadata.gz: e16455627f58c8793b8c317bde748e5a5288de2ca596bcfb641affc327737bc8de21e0b16ce36d749e53f29d95b773587808989acd792432a2125d7ed0ae8f02
7
- data.tar.gz: 649f1cb0ff5aa498d03cc359d49dd9c77a64e07867f3ca92904a2552bb494d279addeba1cafd0048c68876f6f39e46cf77dfc7fecc7567d5a6c14c6d1ef8232b
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,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require 'logger'
4
2
 
5
3
  module Logister
@@ -7,7 +5,10 @@ module Logister
7
5
  attr_accessor :api_key, :endpoint, :environment, :service, :release, :enabled, :timeout_seconds, :logger,
8
6
  :ignore_exceptions, :ignore_environments, :ignore_paths, :before_notify,
9
7
  :async, :queue_size, :max_retries, :retry_base_interval,
10
- :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
11
12
 
12
13
  def initialize
13
14
  @api_key = ENV['LOGISTER_API_KEY']
@@ -33,6 +34,14 @@ module Logister
33
34
  @capture_db_metrics = false
34
35
  @db_metric_min_duration_ms = 0.0
35
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
36
45
  end
37
46
  end
38
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
@@ -1,83 +1,202 @@
1
- # frozen_string_literal: true
2
-
3
- require 'socket'
1
+ require_relative "context_helpers"
2
+ require_relative "context_store"
4
3
 
5
4
  module Logister
6
5
  class Middleware
7
- # Sensitive param key fragments matched case-insensitively.
8
- SENSITIVE_PARAM_RE = /password|token|secret|api_key|credit_card|cvv|ssn/i.freeze
9
- FILTERED = '[FILTERED]'
6
+ FILTERED_HEADER_PLACEHOLDER = "[FILTERED]".freeze
7
+ SENSITIVE_HEADERS = %w[authorization cookie set-cookie x-api-key x-csrf-token].freeze
10
8
 
11
9
  def initialize(app)
12
10
  @app = app
13
-
14
- # Cache values that are constant for the lifetime of this process so
15
- # they are not recomputed on every error.
16
- @hostname = resolve_hostname.freeze
17
- @app_context = build_app_context.freeze
18
11
  end
19
12
 
20
13
  def call(env)
14
+ Logister::ContextStore.reset_request_scope!
15
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
16
  @app.call(env)
22
17
  rescue StandardError => e
18
+ request = ActionDispatch::Request.new(env)
19
+ request_context = build_request_context(request, env, error: e, started_at: started_at)
20
+
23
21
  Logister.report_error(
24
22
  e,
25
- context: {
26
- request: build_request_context(env),
27
- app: @app_context
28
- }
23
+ context: request_context
29
24
  )
30
25
  raise
26
+ ensure
27
+ request_id = env["action_dispatch.request_id"]
28
+ Logister::ContextStore.clear_request_summary(request_id)
29
+ Logister::ContextStore.reset_request_scope!
31
30
  end
32
31
 
33
32
  private
34
33
 
35
- def build_request_context(env)
36
- ctx = {
37
- id: env['action_dispatch.request_id'],
38
- path: env['PATH_INFO'],
39
- method: env['REQUEST_METHOD'],
40
- ip: remote_ip(env),
41
- user_agent: env['HTTP_USER_AGENT']
42
- }
43
-
44
- # Params — available if ActionDispatch has already parsed them.
45
- if (params = env['action_dispatch.request.parameters'])
46
- ctx[:params] = filter_params(params)
34
+ def build_request_context(request, env, error:, started_at:)
35
+ request_id = env["action_dispatch.request_id"].to_s.presence
36
+ path = request.path.to_s
37
+ method = request.request_method.to_s
38
+ params = request.filtered_parameters.to_h
39
+ headers = extract_headers(env)
40
+ referer = request.referer.to_s.presence || headers["Referer"]
41
+ http_version = env["HTTP_VERSION"].to_s.presence || env["SERVER_PROTOCOL"].to_s.presence
42
+ rails_action = rails_action_name(params)
43
+ response_status = response_status_for(error)
44
+ duration_ms = elapsed_duration_ms(started_at)
45
+ current_user = current_user(env)
46
+ user_context = Logister::ContextHelpers.user_context_for(current_user)
47
+ request_summary = Logister::ContextStore.request_summary(request_id) || {}
48
+ dependencies = collected_dependencies(request: request, env: env)
49
+ breadcrumbs = Logister::ContextStore.breadcrumbs
50
+ feature_flags = Logister::ContextHelpers.resolve_feature_flags(request: request, env: env, user: current_user)
51
+ trace_context = Logister::ContextHelpers.trace_context(headers: headers, env: env)
52
+ client_ip = Logister::ContextHelpers.anonymize_ip(request.ip.to_s.presence)
53
+
54
+ base_context = {
55
+ request_id: request_id,
56
+ path: path,
57
+ method: method,
58
+ clientIp: client_ip,
59
+ headers: headers,
60
+ httpMethod: method,
61
+ httpVersion: http_version,
62
+ params: params,
63
+ railsAction: rails_action,
64
+ referer: referer,
65
+ requestId: request_id,
66
+ url: request.original_url.to_s.presence,
67
+ response: {
68
+ status: request_summary[:status] || response_status,
69
+ contentType: request.content_type.to_s.presence,
70
+ format: request_summary[:format] || request.format&.to_s.presence,
71
+ durationMs: duration_ms
72
+ }.compact,
73
+ route: {
74
+ name: env["action_dispatch.route_name"].to_s.presence,
75
+ pathTemplate: route_path_template(env),
76
+ controller: request_summary[:controller] || route_value(params, "controller"),
77
+ action: request_summary[:action] || route_value(params, "action")
78
+ }.compact,
79
+ performance: {
80
+ dbRuntimeMs: request_summary[:dbRuntimeMs],
81
+ viewRuntimeMs: request_summary[:viewRuntimeMs],
82
+ allocations: request_summary[:allocations]
83
+ }.compact,
84
+ dependencyCalls: dependencies.presence,
85
+ breadcrumbs: breadcrumbs.presence,
86
+ request: {
87
+ clientIp: client_ip,
88
+ headers: headers,
89
+ httpMethod: method,
90
+ httpVersion: http_version,
91
+ params: params,
92
+ railsAction: rails_action,
93
+ referer: referer,
94
+ requestId: request_id,
95
+ url: request.original_url.to_s.presence
96
+ }.compact
97
+ }.compact
98
+
99
+ Logister::ContextHelpers.compact_deep(
100
+ base_context
101
+ .merge(trace_context)
102
+ .merge(feature_flags)
103
+ .merge(user_context)
104
+ .merge(Logister::ContextHelpers.runtime_context)
105
+ .merge(Logister::ContextHelpers.deployment_context)
106
+ )
107
+ end
108
+
109
+ def rails_action_name(params)
110
+ return nil unless params.is_a?(Hash)
111
+
112
+ controller_name = params["controller"].to_s.presence || params[:controller].to_s.presence
113
+ action_name = params["action"].to_s.presence || params[:action].to_s.presence
114
+ return nil if controller_name.blank? || action_name.blank?
115
+
116
+ "#{controller_name}##{action_name}"
117
+ end
118
+
119
+ def extract_headers(env)
120
+ headers = {}
121
+
122
+ env.each do |key, value|
123
+ next unless value.is_a?(String)
124
+
125
+ header_name = rack_env_to_header_name(key)
126
+ next unless header_name
127
+
128
+ headers[header_name] = filter_header_value(header_name, value)
47
129
  end
48
130
 
49
- ctx.compact
131
+ headers.sort.to_h
50
132
  end
51
133
 
52
- def build_app_context
53
- ctx = { ruby: RUBY_VERSION, hostname: @hostname }
54
- ctx[:rails] = Rails::VERSION::STRING if defined?(Rails::VERSION)
55
- ctx
134
+ def rack_env_to_header_name(key)
135
+ if key.start_with?("HTTP_")
136
+ key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
137
+ elsif key == "CONTENT_TYPE"
138
+ "Content-Type"
139
+ elsif key == "CONTENT_LENGTH"
140
+ "Content-Length"
141
+ else
142
+ nil
143
+ end
56
144
  end
57
145
 
58
- # Respect X-Forwarded-For set by proxies; fall back to REMOTE_ADDR.
59
- def remote_ip(env)
60
- forwarded = env['HTTP_X_FORWARDED_FOR']
61
- return env['REMOTE_ADDR'] if forwarded.nil? || forwarded.empty?
146
+ def filter_header_value(name, value)
147
+ return FILTERED_HEADER_PLACEHOLDER if SENSITIVE_HEADERS.include?(name.to_s.downcase)
62
148
 
63
- first = forwarded.split(',').first
64
- first ? first.strip : env['REMOTE_ADDR']
149
+ value
65
150
  end
66
151
 
67
- # Filter out sensitive parameter values using a single Regexp so we avoid
68
- # allocating a downcased String for every param key on every error.
69
- def filter_params(params)
70
- params.each_with_object({}) do |(k, v), h|
71
- h[k] = k.to_s.match?(SENSITIVE_PARAM_RE) ? FILTERED : v
152
+ def current_user(env)
153
+ controller = env["action_controller.instance"]
154
+ return nil unless controller
155
+
156
+ if controller.respond_to?(:current_user)
157
+ controller.public_send(:current_user)
158
+ elsif controller.respond_to?(:current_user, true)
159
+ controller.send(:current_user)
72
160
  end
73
161
  rescue StandardError
74
- {}
162
+ nil
163
+ end
164
+
165
+ def collected_dependencies(request:, env:)
166
+ custom = Logister::ContextHelpers.resolve_dependency_context(request: request, env: env).fetch(:dependencyCalls, [])
167
+ manual = Logister::ContextStore.dependencies
168
+ Array(manual) + Array(custom)
169
+ end
170
+
171
+ def response_status_for(error)
172
+ return 500 unless defined?(ActionDispatch::ExceptionWrapper)
173
+
174
+ ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name)
175
+ rescue StandardError
176
+ 500
75
177
  end
76
178
 
77
- def resolve_hostname
78
- Socket.gethostname
179
+ def elapsed_duration_ms(started_at)
180
+ return nil unless started_at
181
+
182
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(2)
79
183
  rescue StandardError
80
- 'unknown'
184
+ nil
185
+ end
186
+
187
+ def route_path_template(env)
188
+ pattern = env["action_dispatch.route_uri_pattern"]
189
+ return pattern.spec.to_s.presence if pattern.respond_to?(:spec)
190
+
191
+ pattern.to_s.presence
192
+ rescue StandardError
193
+ nil
194
+ end
195
+
196
+ def route_value(params, key)
197
+ return nil unless params.is_a?(Hash)
198
+
199
+ params[key].to_s.presence || params[key.to_sym].to_s.presence
81
200
  end
82
201
  end
83
202
  end
@@ -1,6 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  require 'rails/railtie'
2
+ require_relative 'active_job_reporter'
4
3
 
5
4
  module Logister
6
5
  class Railtie < Rails::Railtie
@@ -26,6 +25,13 @@ module Logister
26
25
  copy_setting(app, config, :capture_db_metrics)
27
26
  copy_setting(app, config, :db_metric_min_duration_ms)
28
27
  copy_setting(app, config, :db_metric_sample_rate)
28
+ copy_setting(app, config, :feature_flags_resolver)
29
+ copy_setting(app, config, :dependency_resolver)
30
+ copy_setting(app, config, :anonymize_ip)
31
+ copy_setting(app, config, :max_breadcrumbs)
32
+ copy_setting(app, config, :max_dependencies)
33
+ copy_setting(app, config, :capture_sql_breadcrumbs)
34
+ copy_setting(app, config, :sql_breadcrumb_min_duration_ms)
29
35
  end
30
36
  end
31
37
 
@@ -37,13 +43,23 @@ module Logister
37
43
  Logister::SqlSubscriber.install!
38
44
  end
39
45
 
46
+ initializer "logister.request_subscriber" do
47
+ Logister::RequestSubscriber.install!
48
+ end
49
+
50
+ initializer "logister.active_job_reporter" do
51
+ ActiveSupport.on_load(:active_job) do
52
+ Logister::ActiveJobReporter.install!
53
+ end
54
+ end
55
+
40
56
  private
41
57
 
42
58
  def copy_setting(app, config, key)
43
- value = app.config.logister.public_send(key)
59
+ value = app.config.logister.send(key)
44
60
  return if value.nil?
45
61
 
46
- config.public_send(:"#{key}=", value)
62
+ config.send("#{key}=", value)
47
63
  end
48
64
  end
49
65
  end
@@ -0,0 +1,104 @@
1
+ require "logger"
2
+
3
+ module Logister
4
+ class RequestSubscriber
5
+ IGNORED_SQL_NAMES = %w[SCHEMA TRANSACTION].freeze
6
+
7
+ class << self
8
+ def install!
9
+ return if @installed
10
+
11
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |_name, _started, _finished, _id, payload|
12
+ handle_process_action(payload)
13
+ end
14
+
15
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, started, finished, _id, payload|
16
+ handle_sql_breadcrumb(started, finished, payload)
17
+ end
18
+
19
+ @installed = true
20
+ end
21
+
22
+ private
23
+
24
+ def handle_process_action(payload)
25
+ return unless payload.is_a?(Hash)
26
+
27
+ request_id = payload[:request_id].to_s.presence
28
+ return unless request_id
29
+
30
+ Logister::ContextStore.store_request_summary(
31
+ request_id,
32
+ {
33
+ status: payload[:status],
34
+ format: payload[:format].to_s.presence,
35
+ method: payload[:method].to_s.presence,
36
+ path: payload[:path].to_s.presence,
37
+ controller: payload[:controller].to_s.presence,
38
+ action: payload[:action].to_s.presence,
39
+ dbRuntimeMs: numeric(payload[:db_runtime]),
40
+ viewRuntimeMs: numeric(payload[:view_runtime]),
41
+ allocations: payload[:allocations]
42
+ }.compact
43
+ )
44
+
45
+ Logister.add_breadcrumb(
46
+ category: "request",
47
+ message: "#{payload[:controller]}##{payload[:action]} completed",
48
+ data: {
49
+ status: payload[:status],
50
+ method: payload[:method],
51
+ path: payload[:path],
52
+ dbRuntimeMs: numeric(payload[:db_runtime]),
53
+ viewRuntimeMs: numeric(payload[:view_runtime])
54
+ }.compact
55
+ )
56
+ rescue StandardError => e
57
+ logger.warn("logister request subscriber (process_action) failed: #{e.class} #{e.message}")
58
+ end
59
+
60
+ def handle_sql_breadcrumb(started, finished, payload)
61
+ config = configuration
62
+ return unless config&.capture_sql_breadcrumbs
63
+ return unless payload.is_a?(Hash)
64
+ return if payload[:cached]
65
+ return if IGNORED_SQL_NAMES.include?(payload[:name].to_s)
66
+
67
+ duration_ms = ((finished - started) * 1000.0).round(2)
68
+ return if duration_ms < config.sql_breadcrumb_min_duration_ms.to_f
69
+
70
+ sql_name = payload[:name].to_s.presence || "SQL"
71
+ Logister.add_breadcrumb(
72
+ category: "db",
73
+ message: "#{sql_name} query",
74
+ data: {
75
+ durationMs: duration_ms,
76
+ sql: payload[:sql].to_s[0, 250]
77
+ }
78
+ )
79
+ rescue StandardError => e
80
+ logger.warn("logister request subscriber (sql breadcrumb) failed: #{e.class} #{e.message}")
81
+ end
82
+
83
+ def numeric(value)
84
+ return nil if value.nil?
85
+
86
+ value.to_f.round(2)
87
+ end
88
+
89
+ def configuration
90
+ return nil unless Logister.respond_to?(:configuration)
91
+
92
+ Logister.configuration
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ def logger
98
+ configuration&.logger || Logger.new($stdout)
99
+ rescue StandardError
100
+ Logger.new($stdout)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Logister
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.2'
5
5
  end
data/lib/logister.rb CHANGED
@@ -1,11 +1,12 @@
1
- # frozen_string_literal: true
2
-
3
1
  require_relative 'logister/version'
4
2
  require_relative 'logister/configuration'
5
3
  require_relative 'logister/client'
6
4
  require_relative 'logister/reporter'
5
+ require_relative 'logister/context_helpers'
6
+ require_relative 'logister/context_store'
7
7
  require_relative 'logister/middleware'
8
8
  require_relative 'logister/sql_subscriber'
9
+ require_relative 'logister/request_subscriber'
9
10
 
10
11
  module Logister
11
12
  class << self
@@ -30,14 +31,6 @@ module Logister
30
31
  reporter.report_metric(**kwargs)
31
32
  end
32
33
 
33
- def set_user(id: nil, email: nil, name: nil, **extra)
34
- reporter.set_user(id: id, email: email, name: name, **extra)
35
- end
36
-
37
- def clear_user
38
- reporter.clear_user
39
- end
40
-
41
34
  def flush(timeout: 2)
42
35
  reporter.flush(timeout: timeout)
43
36
  end
@@ -45,6 +38,27 @@ module Logister
45
38
  def shutdown
46
39
  reporter.shutdown
47
40
  end
41
+
42
+ def add_breadcrumb(category:, message:, data: {}, level: "info")
43
+ ContextStore.add_manual_breadcrumb(
44
+ category: category,
45
+ message: message,
46
+ data: data,
47
+ level: level
48
+ )
49
+ end
50
+
51
+ def add_dependency(name:, host: nil, method: nil, status: nil, duration_ms: nil, kind: nil, data: {})
52
+ ContextStore.add_manual_dependency(
53
+ name: name,
54
+ host: host,
55
+ method: method,
56
+ status: status,
57
+ duration_ms: duration_ms,
58
+ kind: kind,
59
+ data: data
60
+ )
61
+ end
48
62
  end
49
63
  end
50
64
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logister-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Logister
@@ -50,11 +50,15 @@ files:
50
50
  - lib/generators/logister/templates/logister.rb
51
51
  - lib/logister-ruby.rb
52
52
  - lib/logister.rb
53
+ - lib/logister/active_job_reporter.rb
53
54
  - lib/logister/client.rb
54
55
  - lib/logister/configuration.rb
56
+ - lib/logister/context_helpers.rb
57
+ - lib/logister/context_store.rb
55
58
  - lib/logister/middleware.rb
56
59
  - lib/logister/railtie.rb
57
60
  - lib/logister/reporter.rb
61
+ - lib/logister/request_subscriber.rb
58
62
  - lib/logister/sql_subscriber.rb
59
63
  - lib/logister/version.rb
60
64
  - logister-ruby.gemspec