logister-ruby 0.1.2 → 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: c3ffbd6d90538c5e0cf39deab256aaf1b0c5c15f5899051e67cfedc6657ac4b5
4
- data.tar.gz: af860be23dab9bd73c8d394e9b1b91b6076f62a6a5f67272991afafba75d83a6
3
+ metadata.gz: d6f55dcaf8248136d818bf775ec1735e6b5340a83b4226eec28540679c3c2725
4
+ data.tar.gz: 773b83846639292bbdb935a333f3384f6d3b737c1d3933044a7d6a8c59884d84
5
5
  SHA512:
6
- metadata.gz: 59875a74fdee52e22b697b80e4840ba070db495eb905b47d6522576f2e745d4f64081bf0fbf5f8e7cfc48917466c1a8724a389fe36f728b29080ff71dcb79165
7
- data.tar.gz: fb8e6d1898621e57f2fdb9c66a3e08b655151f3f06ed830e5e2a8c96313c4b1e181c1281846a2a62ada0d7332d8a9631630bb5b9abe9bf7a4fc4f14e04a781a3
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,7 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
1
5
  module Logister
2
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
+
3
11
  def initialize(app)
4
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
5
18
  end
6
19
 
7
20
  def call(env)
@@ -10,12 +23,61 @@ module Logister
10
23
  Logister.report_error(
11
24
  e,
12
25
  context: {
13
- request_id: env['action_dispatch.request_id'],
14
- path: env['PATH_INFO'],
15
- method: env['REQUEST_METHOD']
26
+ request: build_request_context(env),
27
+ app: @app_context
16
28
  }
17
29
  )
18
30
  raise
19
31
  end
32
+
33
+ private
34
+
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)
47
+ end
48
+
49
+ ctx.compact
50
+ end
51
+
52
+ def build_app_context
53
+ ctx = { ruby: RUBY_VERSION, hostname: @hostname }
54
+ ctx[:rails] = Rails::VERSION::STRING if defined?(Rails::VERSION)
55
+ ctx
56
+ end
57
+
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?
62
+
63
+ first = forwarded.split(',').first
64
+ first ? first.strip : env['REMOTE_ADDR']
65
+ end
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.
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
72
+ end
73
+ rescue StandardError
74
+ {}
75
+ end
76
+
77
+ def resolve_hostname
78
+ Socket.gethostname
79
+ rescue StandardError
80
+ 'unknown'
81
+ end
20
82
  end
21
83
  end
@@ -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,28 +1,59 @@
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)
14
41
  return false if ignored_exception?(exception)
15
42
  return false if ignored_path?(context)
16
43
 
44
+ merged_context = context.dup
45
+ user = current_user_context
46
+ merged_context[:user] = user if user
47
+
17
48
  payload = build_payload(
18
- event_type: 'error',
19
- level: level,
20
- message: "#{exception.class}: #{exception.message}",
49
+ event_type: 'error',
50
+ level: level,
51
+ message: "#{exception.class}: #{exception.message}",
21
52
  fingerprint: fingerprint || default_fingerprint(exception),
22
- context: context.merge(
53
+ context: merged_context.merge(
23
54
  exception: {
24
- class: exception.class.to_s,
25
- message: exception.message.to_s,
55
+ class: exception.class.to_s,
56
+ message: exception.message.to_s,
26
57
  backtrace: Array(exception.backtrace).first(50)
27
58
  },
28
59
  tags: tags
@@ -40,11 +71,11 @@ module Logister
40
71
  return false if ignored_path?(context)
41
72
 
42
73
  payload = build_payload(
43
- event_type: 'metric',
44
- level: level,
45
- message: message,
46
- fingerprint: fingerprint || Digest::SHA256.hexdigest(message.to_s)[0, 32],
47
- 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)
48
79
  )
49
80
 
50
81
  payload = apply_before_notify(payload)
@@ -53,6 +84,20 @@ module Logister
53
84
  @client.publish(payload)
54
85
  end
55
86
 
87
+ # Store user info for the current thread so it is automatically attached to
88
+ # every error reported during this request.
89
+ #
90
+ # Logister.set_user(id: current_user.id, email: current_user.email, name: current_user.name)
91
+ #
92
+ def set_user(id: nil, email: nil, name: nil, **extra)
93
+ ctx = { id: id, email: email, name: name }.merge(extra).compact
94
+ Thread.current[:logister_user] = ctx.empty? ? nil : ctx
95
+ end
96
+
97
+ def clear_user
98
+ Thread.current[:logister_user] = nil
99
+ end
100
+
56
101
  def flush(timeout: 2)
57
102
  @client.flush(timeout: timeout)
58
103
  end
@@ -63,18 +108,31 @@ module Logister
63
108
 
64
109
  private
65
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
+
121
+ def current_user_context
122
+ Thread.current[:logister_user]
123
+ end
124
+
66
125
  def build_payload(event_type:, level:, message:, fingerprint:, context:)
67
126
  {
68
- event_type: event_type,
69
- level: level,
70
- message: message,
127
+ event_type: event_type,
128
+ level: level,
129
+ message: message,
71
130
  fingerprint: fingerprint,
72
131
  occurred_at: Time.now.utc.iso8601,
73
- context: context.merge(
74
- environment: @configuration.environment,
75
- service: @configuration.service,
76
- release: @configuration.release
77
- )
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)
78
136
  }
79
137
  end
80
138
 
@@ -104,21 +162,58 @@ module Logister
104
162
  end
105
163
 
106
164
  def ignored_environment?
107
- env = @configuration.environment.to_s
108
- @configuration.ignore_environments.map(&:to_s).include?(env)
165
+ @ignored_envs.include?(@current_env)
109
166
  end
110
167
 
111
168
  def ignored_path?(context)
112
169
  path = context[:path] || context['path']
113
170
  return false if path.to_s.empty?
114
171
 
172
+ path_s = path.to_s
115
173
  @configuration.ignore_paths.any? do |matcher|
116
- 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)
117
175
  end
118
176
  end
119
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
+
120
187
  def default_fingerprint(exception)
121
- Digest::SHA256.hexdigest("#{exception.class}|#{exception.message}")[0, 32]
188
+ # Prefer class + first backtrace location so that errors with dynamic
189
+ # values in their message (e.g. "Couldn't find User with 'id'=42") still
190
+ # group together across different IDs / UUIDs.
191
+ location = Array(exception.backtrace).first.to_s
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)
195
+
196
+ if location.empty?
197
+ # No backtrace — scrub dynamic tokens from the message before hashing.
198
+ scrubbed = scrub_dynamic_values(exception.message.to_s)
199
+ Digest::SHA256.hexdigest("#{exception.class}|#{scrubbed}")[0, 32]
200
+ else
201
+ Digest::SHA256.hexdigest("#{exception.class}|#{location}")[0, 32]
202
+ end
203
+ end
204
+
205
+ # Strip values that vary per-occurrence but carry no grouping signal:
206
+ # - numeric IDs: id=42, 'id'=42, id: 42
207
+ # - UUIDs
208
+ # - hex digests (≥8 hex chars)
209
+ # - quoted string values in ActiveRecord-style messages
210
+ def scrub_dynamic_values(message)
211
+ message
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+/, '?')
122
217
  end
123
218
  end
124
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.1.2'
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'
@@ -28,6 +30,14 @@ module Logister
28
30
  reporter.report_metric(**kwargs)
29
31
  end
30
32
 
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
+
31
41
  def flush(timeout: 2)
32
42
  reporter.flush(timeout: timeout)
33
43
  end
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.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Logister