logister-ruby 0.2.0 → 0.2.1

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: d6f55dcaf8248136d818bf775ec1735e6b5340a83b4226eec28540679c3c2725
4
+ data.tar.gz: 773b83846639292bbdb935a333f3384f6d3b737c1d3933044a7d6a8c59884d84
5
5
  SHA512:
6
- metadata.gz: 9a8dbbbdde7778369654c287f9076a89efaadf5a3d03bdd5cb064546bac2b2b72bd53d641b424d3b4bc3cefecfce0bfd1b0a9c50714a8c58506b21c6765b8902
7
- data.tar.gz: 823a8c724d3aee752558e9936c23f924c774157c83a7b997885ba19a8dbf6aa835277506101f2eea341dde05923353f58127f6b94c0d8cbb6711823c5f43c946
6
+ metadata.gz: e16455627f58c8793b8c317bde748e5a5288de2ca596bcfb641affc327737bc8de21e0b16ce36d749e53f29d95b773587808989acd792432a2125d7ed0ae8f02
7
+ data.tar.gz: 649f1cb0ff5aa498d03cc359d49dd9c77a64e07867f3ca92904a2552bb494d279addeba1cafd0048c68876f6f39e46cf77dfc7fecc7567d5a6c14c6d1ef8232b
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'logger'
2
4
 
3
5
  module Logister
@@ -1,9 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'socket'
2
4
 
3
5
  module Logister
4
6
  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]'
10
+
5
11
  def initialize(app)
6
12
  @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
7
18
  end
8
19
 
9
20
  def call(env)
@@ -13,7 +24,7 @@ module Logister
13
24
  e,
14
25
  context: {
15
26
  request: build_request_context(env),
16
- app: build_app_context
27
+ app: @app_context
17
28
  }
18
29
  )
19
30
  raise
@@ -23,14 +34,14 @@ module Logister
23
34
 
24
35
  def build_request_context(env)
25
36
  ctx = {
26
- id: env['action_dispatch.request_id'],
27
- path: env['PATH_INFO'],
28
- method: env['REQUEST_METHOD'],
29
- ip: remote_ip(env),
37
+ id: env['action_dispatch.request_id'],
38
+ path: env['PATH_INFO'],
39
+ method: env['REQUEST_METHOD'],
40
+ ip: remote_ip(env),
30
41
  user_agent: env['HTTP_USER_AGENT']
31
42
  }
32
43
 
33
- # Params — available if ActionDispatch has already parsed them
44
+ # Params — available if ActionDispatch has already parsed them.
34
45
  if (params = env['action_dispatch.request.parameters'])
35
46
  ctx[:params] = filter_params(params)
36
47
  end
@@ -39,33 +50,31 @@ module Logister
39
50
  end
40
51
 
41
52
  def build_app_context
42
- ctx = {
43
- ruby: RUBY_VERSION,
44
- hostname: hostname
45
- }
53
+ ctx = { ruby: RUBY_VERSION, hostname: @hostname }
46
54
  ctx[:rails] = Rails::VERSION::STRING if defined?(Rails::VERSION)
47
55
  ctx
48
56
  end
49
57
 
50
- # Respect X-Forwarded-For set by proxies, fall back to REMOTE_ADDR
58
+ # Respect X-Forwarded-For set by proxies; fall back to REMOTE_ADDR.
51
59
  def remote_ip(env)
52
- forwarded = env['HTTP_X_FORWARDED_FOR'].to_s.split(',').first&.strip
53
- forwarded.nil? || forwarded.empty? ? env['REMOTE_ADDR'] : forwarded
54
- end
60
+ forwarded = env['HTTP_X_FORWARDED_FOR']
61
+ return env['REMOTE_ADDR'] if forwarded.nil? || forwarded.empty?
55
62
 
56
- # Remove sensitive parameter values the same way Rails does
57
- SENSITIVE_PARAMS = %w[password password_confirmation token secret api_key
58
- credit_card cvv ssn].freeze
63
+ first = forwarded.split(',').first
64
+ first ? first.strip : env['REMOTE_ADDR']
65
+ end
59
66
 
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.
60
69
  def filter_params(params)
61
70
  params.each_with_object({}) do |(k, v), h|
62
- h[k] = SENSITIVE_PARAMS.any? { |s| k.to_s.downcase.include?(s) } ? '[FILTERED]' : v
71
+ h[k] = k.to_s.match?(SENSITIVE_PARAM_RE) ? FILTERED : v
63
72
  end
64
73
  rescue StandardError
65
74
  {}
66
75
  end
67
76
 
68
- def hostname
77
+ def resolve_hostname
69
78
  Socket.gethostname
70
79
  rescue StandardError
71
80
  'unknown'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rails/railtie'
2
4
 
3
5
  module Logister
@@ -38,10 +40,10 @@ module Logister
38
40
  private
39
41
 
40
42
  def copy_setting(app, config, key)
41
- value = app.config.logister.send(key)
43
+ value = app.config.logister.public_send(key)
42
44
  return if value.nil?
43
45
 
44
- config.send("#{key}=", value)
46
+ config.public_send(:"#{key}=", value)
45
47
  end
46
48
  end
47
49
  end
@@ -1,13 +1,40 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'digest'
2
4
  require 'time'
5
+ require 'set'
3
6
 
4
7
  module Logister
5
8
  class Reporter
6
9
  def initialize(configuration)
7
10
  @configuration = configuration
8
- @client = Client.new(configuration)
9
-
10
- at_exit { shutdown }
11
+ @client = Client.new(configuration)
12
+
13
+ # Pre-build values that are static for the lifetime of this reporter so
14
+ # they are not allocated on every report_error / report_metric call.
15
+ @static_context = {
16
+ environment: @configuration.environment,
17
+ service: @configuration.service,
18
+ release: @configuration.release
19
+ }.freeze
20
+
21
+ # Normalise ignore_environments once into a frozen Set of Strings so
22
+ # ignored_environment? never allocates a mapped Array.
23
+ @ignored_envs = Set.new(@configuration.ignore_environments.map(&:to_s)).freeze
24
+
25
+ # Cache the current-environment String to avoid repeated .to_s calls.
26
+ @current_env = @configuration.environment.to_s.freeze
27
+
28
+ # Compile the app-root stripping Regexp once; Dir.pwd is a syscall that
29
+ # always returns a new String — do it exactly once here.
30
+ app_root = Dir.pwd.to_s.freeze
31
+ @app_root_re = /\A#{Regexp.escape(app_root)}\//.freeze
32
+
33
+ # Register shutdown hook. Guard with a flag so multiple Reporter instances
34
+ # (created by repeated Logister.configure calls) each shut down cleanly
35
+ # without re-registering more handlers.
36
+ @shutdown_registered = false
37
+ register_shutdown_hook
11
38
  end
12
39
 
13
40
  def report_error(exception, context: {}, tags: {}, level: 'error', fingerprint: nil)
@@ -15,17 +42,18 @@ module Logister
15
42
  return false if ignored_path?(context)
16
43
 
17
44
  merged_context = context.dup
18
- merged_context[:user] = current_user_context if current_user_context
45
+ user = current_user_context
46
+ merged_context[:user] = user if user
19
47
 
20
48
  payload = build_payload(
21
- event_type: 'error',
22
- level: level,
23
- message: "#{exception.class}: #{exception.message}",
49
+ event_type: 'error',
50
+ level: level,
51
+ message: "#{exception.class}: #{exception.message}",
24
52
  fingerprint: fingerprint || default_fingerprint(exception),
25
- context: merged_context.merge(
53
+ context: merged_context.merge(
26
54
  exception: {
27
- class: exception.class.to_s,
28
- message: exception.message.to_s,
55
+ class: exception.class.to_s,
56
+ message: exception.message.to_s,
29
57
  backtrace: Array(exception.backtrace).first(50)
30
58
  },
31
59
  tags: tags
@@ -43,11 +71,11 @@ module Logister
43
71
  return false if ignored_path?(context)
44
72
 
45
73
  payload = build_payload(
46
- event_type: 'metric',
47
- level: level,
48
- message: message,
49
- fingerprint: fingerprint || Digest::SHA256.hexdigest(message.to_s)[0, 32],
50
- context: context.merge(tags: tags)
74
+ event_type: 'metric',
75
+ level: level,
76
+ message: message,
77
+ fingerprint: fingerprint || metric_fingerprint(message),
78
+ context: context.merge(tags: tags)
51
79
  )
52
80
 
53
81
  payload = apply_before_notify(payload)
@@ -80,22 +108,31 @@ module Logister
80
108
 
81
109
  private
82
110
 
111
+ def register_shutdown_hook
112
+ return if @shutdown_registered
113
+
114
+ @shutdown_registered = true
115
+ # Capture @client directly (not self) so the at_exit proc does not
116
+ # retain the entire Reporter in the finalizer chain.
117
+ client = @client
118
+ at_exit { client.shutdown }
119
+ end
120
+
83
121
  def current_user_context
84
122
  Thread.current[:logister_user]
85
123
  end
86
124
 
87
125
  def build_payload(event_type:, level:, message:, fingerprint:, context:)
88
126
  {
89
- event_type: event_type,
90
- level: level,
91
- message: message,
127
+ event_type: event_type,
128
+ level: level,
129
+ message: message,
92
130
  fingerprint: fingerprint,
93
131
  occurred_at: Time.now.utc.iso8601,
94
- context: context.merge(
95
- environment: @configuration.environment,
96
- service: @configuration.service,
97
- release: @configuration.release
98
- )
132
+ # Merge static config context last so caller-supplied keys are not
133
+ # overwritten, then merge the static values. The static_context Hash
134
+ # is frozen and reused — only the new outer Hash is allocated.
135
+ context: @static_context.merge(context)
99
136
  }
100
137
  end
101
138
 
@@ -125,31 +162,39 @@ module Logister
125
162
  end
126
163
 
127
164
  def ignored_environment?
128
- env = @configuration.environment.to_s
129
- @configuration.ignore_environments.map(&:to_s).include?(env)
165
+ @ignored_envs.include?(@current_env)
130
166
  end
131
167
 
132
168
  def ignored_path?(context)
133
169
  path = context[:path] || context['path']
134
170
  return false if path.to_s.empty?
135
171
 
172
+ path_s = path.to_s
136
173
  @configuration.ignore_paths.any? do |matcher|
137
- matcher.is_a?(Regexp) ? matcher.match?(path.to_s) : path.to_s.include?(matcher.to_s)
174
+ matcher.is_a?(Regexp) ? matcher.match?(path_s) : path_s.include?(matcher.to_s)
138
175
  end
139
176
  end
140
177
 
178
+ # Cache metric fingerprints — metric messages are typically a small fixed
179
+ # set of constants (e.g. 'db.query') so the SHA256 is identical every call.
180
+ def metric_fingerprint(message)
181
+ @metric_fingerprint_cache ||= {}
182
+ key = message.to_s
183
+ @metric_fingerprint_cache[key] ||=
184
+ Digest::SHA256.hexdigest(key)[0, 32].freeze
185
+ end
186
+
141
187
  def default_fingerprint(exception)
142
188
  # Prefer class + first backtrace location so that errors with dynamic
143
189
  # values in their message (e.g. "Couldn't find User with 'id'=42") still
144
190
  # group together across different IDs / UUIDs.
145
191
  location = Array(exception.backtrace).first.to_s
146
- .sub(/:in\s+.+$/, '') # strip method name
147
- .sub(/\A.*\/gems\//, 'gems/') # normalise gem paths
148
- .sub(/\A#{Regexp.escape(Dir.pwd.to_s)}\//, '') # strip app root
192
+ .sub(/:in\s+.+$/, '') # strip method name
193
+ .sub(/\A.*\/gems\//, 'gems/') # normalise gem paths
194
+ .sub(@app_root_re, '') # strip app root (pre-compiled RE)
149
195
 
150
196
  if location.empty?
151
- # No backtrace available — scrub common dynamic tokens from the message
152
- # before hashing so that e.g. "id=42" and "id=99" hash the same way.
197
+ # No backtrace — scrub dynamic tokens from the message before hashing.
153
198
  scrubbed = scrub_dynamic_values(exception.message.to_s)
154
199
  Digest::SHA256.hexdigest("#{exception.class}|#{scrubbed}")[0, 32]
155
200
  else
@@ -157,18 +202,18 @@ module Logister
157
202
  end
158
203
  end
159
204
 
160
- # Strip values that tend to vary per-occurrence but carry no grouping signal:
161
- # - numeric IDs: id=42, 'id'=42, id: 42
205
+ # Strip values that vary per-occurrence but carry no grouping signal:
206
+ # - numeric IDs: id=42, 'id'=42, id: 42
162
207
  # - UUIDs
163
208
  # - hex digests (≥8 hex chars)
164
209
  # - quoted string values in ActiveRecord-style messages
165
210
  def scrub_dynamic_values(message)
166
211
  message
167
- .gsub(/\b(id['"]?\s*[=:]\s*)\d+/i, '\1?')
168
- .gsub(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, '?')
169
- .gsub(/\b[0-9a-f]{8,}\b/, '?')
170
- .gsub(/'[^']{1,64}'/, '?')
171
- .gsub(/\d+/, '?')
212
+ .gsub(/\b(id['"]?\s*[=:]\s*)\d+/i, '\1?')
213
+ .gsub(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, '?')
214
+ .gsub(/\b[0-9a-f]{8,}\b/, '?')
215
+ .gsub(/'[^']{1,64}'/, '?')
216
+ .gsub(/\d+/, '?')
172
217
  end
173
218
  end
174
219
  end
@@ -1,13 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Logister
2
4
  class SqlSubscriber
3
5
  IGNORED_SQL_NAMES = %w[SCHEMA TRANSACTION].freeze
4
6
 
7
+ # Frozen constants for values emitted on every captured query.
8
+ MESSAGE = 'db.query'
9
+ LEVEL_WARN = 'warn'
10
+ LEVEL_INFO = 'info'
11
+ TAGS = { category: 'database' }.freeze
12
+
13
+ # Pre-compute the fingerprint for the fixed message string so we pay the
14
+ # SHA256 cost exactly once instead of on every captured SQL query.
15
+ require 'digest'
16
+ SQL_FINGERPRINT = Digest::SHA256.hexdigest(MESSAGE)[0, 32].freeze
17
+
5
18
  class << self
6
19
  def install!
7
20
  return if @installed
8
21
 
9
- ActiveSupport::Notifications.subscribe('sql.active_record') do |name, started, finished, _id, payload|
10
- handle_sql_event(name, started, finished, payload)
22
+ ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, started, finished, _id, payload|
23
+ handle_sql_event(started, finished, payload)
11
24
  end
12
25
 
13
26
  @installed = true
@@ -15,31 +28,34 @@ module Logister
15
28
 
16
29
  private
17
30
 
18
- def handle_sql_event(_name, started, finished, payload)
31
+ def handle_sql_event(started, finished, payload)
19
32
  config = Logister.configuration
33
+
34
+ # Short-circuit as cheaply as possible when metrics are disabled so
35
+ # that *every* SQL query in the app pays minimal overhead.
20
36
  return unless config.capture_db_metrics
21
37
  return if payload[:cached]
22
- return if IGNORED_SQL_NAMES.include?(payload[:name].to_s)
38
+
39
+ # Evaluate name once — it's used in two places below.
40
+ sql_name = payload[:name].to_s
41
+ return if IGNORED_SQL_NAMES.include?(sql_name)
23
42
 
24
43
  duration_ms = (finished - started) * 1000.0
25
44
  return if duration_ms < config.db_metric_min_duration_ms.to_f
26
45
  return if sampled_out?(config.db_metric_sample_rate)
27
46
 
28
- level = duration_ms >= 500 ? 'warn' : 'info'
29
-
30
47
  Logister.report_metric(
31
- message: 'db.query',
32
- level: level,
48
+ message: MESSAGE,
49
+ level: duration_ms >= 500 ? LEVEL_WARN : LEVEL_INFO,
50
+ fingerprint: SQL_FINGERPRINT,
33
51
  context: {
34
52
  duration_ms: duration_ms.round(2),
35
- name: payload[:name].to_s,
36
- sql: payload[:sql].to_s,
37
- cached: false,
38
- binds_count: Array(payload[:binds]).size
53
+ name: sql_name,
54
+ sql: payload[:sql].to_s,
55
+ cached: false,
56
+ binds_count: (payload[:binds] || []).size
39
57
  },
40
- tags: {
41
- category: 'database'
42
- }
58
+ tags: TAGS
43
59
  )
44
60
  rescue StandardError => e
45
61
  config.logger.warn("logister sql subscriber failed: #{e.class} #{e.message}")
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Logister
2
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
3
5
  end
data/lib/logister.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'logister/version'
2
4
  require_relative 'logister/configuration'
3
5
  require_relative 'logister/client'
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.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Logister