logister-ruby 0.1.1 → 0.2.0

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: cc4c81cdfeef4f713ac40e5324e8d845da2190dacfcaf9e295e0da5d4d1a75a1
4
- data.tar.gz: 3fdb07135981647437afae77a787645c89143847619edc46969f121b750a0c90
3
+ metadata.gz: 2f3f9f67b976e92e535974c8a243b14f011088bca57e5ab3dc0385de6ba4c003
4
+ data.tar.gz: d744d4996b00d27a2e51e54b5b91134f4c0ae049c76270a41c7ef93deec04189
5
5
  SHA512:
6
- metadata.gz: 3d53d3e80a01927a2ae08fec30d30eeb9d7d7987c3e5c1d624433c0683fc25b232675208e62ff7e563a9cbefcd1ae33e0bae32c9a6deb0fb084e307be0755e40
7
- data.tar.gz: c869dc8bb9c120b90d7f13b05ee291f1a82053ff3523a2bd8a4eafdd4159ee8553f975d0afc2eda97f9441a3b25df6f5c0bbf2c813b575f23b01386147e8eec9
6
+ metadata.gz: 9a8dbbbdde7778369654c287f9076a89efaadf5a3d03bdd5cb064546bac2b2b72bd53d641b424d3b4bc3cefecfce0bfd1b0a9c50714a8c58506b21c6765b8902
7
+ data.tar.gz: 823a8c724d3aee752558e9936c23f924c774157c83a7b997885ba19a8dbf6aa835277506101f2eea341dde05923353f58127f6b94c0d8cbb6711823c5f43c946
data/README.md CHANGED
@@ -56,6 +56,20 @@ end
56
56
 
57
57
  If Rails is present, the gem installs middleware that reports unhandled exceptions automatically.
58
58
 
59
+ ## Database load metrics (ActiveRecord)
60
+
61
+ You can capture SQL timing metrics using ActiveSupport notifications:
62
+
63
+ ```ruby
64
+ Logister.configure do |config|
65
+ config.capture_db_metrics = true
66
+ config.db_metric_min_duration_ms = 10.0
67
+ config.db_metric_sample_rate = 1.0
68
+ end
69
+ ```
70
+
71
+ This emits metric events with `message: "db.query"` and context fields such as `duration_ms`, `name`, `sql`, and `binds_count`.
72
+
59
73
  ## Manual reporting
60
74
 
61
75
  ```ruby
@@ -17,6 +17,11 @@ Logister.configure do |config|
17
17
  config.ignore_exceptions = []
18
18
  config.ignore_paths = []
19
19
 
20
+ # Optional ActiveRecord SQL instrumentation.
21
+ config.capture_db_metrics = false
22
+ config.db_metric_min_duration_ms = 10.0
23
+ config.db_metric_sample_rate = 1.0
24
+
20
25
  config.before_notify = lambda do |payload|
21
26
  payload
22
27
  end
@@ -4,7 +4,8 @@ module Logister
4
4
  class Configuration
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
- :async, :queue_size, :max_retries, :retry_base_interval
7
+ :async, :queue_size, :max_retries, :retry_base_interval,
8
+ :capture_db_metrics, :db_metric_min_duration_ms, :db_metric_sample_rate
8
9
 
9
10
  def initialize
10
11
  @api_key = ENV['LOGISTER_API_KEY']
@@ -26,6 +27,10 @@ module Logister
26
27
  @queue_size = 1000
27
28
  @max_retries = 3
28
29
  @retry_base_interval = 0.5
30
+
31
+ @capture_db_metrics = false
32
+ @db_metric_min_duration_ms = 0.0
33
+ @db_metric_sample_rate = 1.0
29
34
  end
30
35
  end
31
36
  end
@@ -1,3 +1,5 @@
1
+ require 'socket'
2
+
1
3
  module Logister
2
4
  class Middleware
3
5
  def initialize(app)
@@ -10,12 +12,63 @@ module Logister
10
12
  Logister.report_error(
11
13
  e,
12
14
  context: {
13
- request_id: env['action_dispatch.request_id'],
14
- path: env['PATH_INFO'],
15
- method: env['REQUEST_METHOD']
15
+ request: build_request_context(env),
16
+ app: build_app_context
16
17
  }
17
18
  )
18
19
  raise
19
20
  end
21
+
22
+ private
23
+
24
+ def build_request_context(env)
25
+ ctx = {
26
+ id: env['action_dispatch.request_id'],
27
+ path: env['PATH_INFO'],
28
+ method: env['REQUEST_METHOD'],
29
+ ip: remote_ip(env),
30
+ user_agent: env['HTTP_USER_AGENT']
31
+ }
32
+
33
+ # Params — available if ActionDispatch has already parsed them
34
+ if (params = env['action_dispatch.request.parameters'])
35
+ ctx[:params] = filter_params(params)
36
+ end
37
+
38
+ ctx.compact
39
+ end
40
+
41
+ def build_app_context
42
+ ctx = {
43
+ ruby: RUBY_VERSION,
44
+ hostname: hostname
45
+ }
46
+ ctx[:rails] = Rails::VERSION::STRING if defined?(Rails::VERSION)
47
+ ctx
48
+ end
49
+
50
+ # Respect X-Forwarded-For set by proxies, fall back to REMOTE_ADDR
51
+ 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
55
+
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
59
+
60
+ def filter_params(params)
61
+ params.each_with_object({}) do |(k, v), h|
62
+ h[k] = SENSITIVE_PARAMS.any? { |s| k.to_s.downcase.include?(s) } ? '[FILTERED]' : v
63
+ end
64
+ rescue StandardError
65
+ {}
66
+ end
67
+
68
+ def hostname
69
+ Socket.gethostname
70
+ rescue StandardError
71
+ 'unknown'
72
+ end
20
73
  end
21
74
  end
@@ -21,6 +21,9 @@ module Logister
21
21
  copy_setting(app, config, :queue_size)
22
22
  copy_setting(app, config, :max_retries)
23
23
  copy_setting(app, config, :retry_base_interval)
24
+ copy_setting(app, config, :capture_db_metrics)
25
+ copy_setting(app, config, :db_metric_min_duration_ms)
26
+ copy_setting(app, config, :db_metric_sample_rate)
24
27
  end
25
28
  end
26
29
 
@@ -28,6 +31,10 @@ module Logister
28
31
  app.middleware.use Logister::Middleware
29
32
  end
30
33
 
34
+ initializer 'logister.sql_subscriber' do
35
+ Logister::SqlSubscriber.install!
36
+ end
37
+
31
38
  private
32
39
 
33
40
  def copy_setting(app, config, key)
@@ -14,12 +14,15 @@ module Logister
14
14
  return false if ignored_exception?(exception)
15
15
  return false if ignored_path?(context)
16
16
 
17
+ merged_context = context.dup
18
+ merged_context[:user] = current_user_context if current_user_context
19
+
17
20
  payload = build_payload(
18
21
  event_type: 'error',
19
22
  level: level,
20
23
  message: "#{exception.class}: #{exception.message}",
21
24
  fingerprint: fingerprint || default_fingerprint(exception),
22
- context: context.merge(
25
+ context: merged_context.merge(
23
26
  exception: {
24
27
  class: exception.class.to_s,
25
28
  message: exception.message.to_s,
@@ -53,6 +56,20 @@ module Logister
53
56
  @client.publish(payload)
54
57
  end
55
58
 
59
+ # Store user info for the current thread so it is automatically attached to
60
+ # every error reported during this request.
61
+ #
62
+ # Logister.set_user(id: current_user.id, email: current_user.email, name: current_user.name)
63
+ #
64
+ def set_user(id: nil, email: nil, name: nil, **extra)
65
+ ctx = { id: id, email: email, name: name }.merge(extra).compact
66
+ Thread.current[:logister_user] = ctx.empty? ? nil : ctx
67
+ end
68
+
69
+ def clear_user
70
+ Thread.current[:logister_user] = nil
71
+ end
72
+
56
73
  def flush(timeout: 2)
57
74
  @client.flush(timeout: timeout)
58
75
  end
@@ -63,6 +80,10 @@ module Logister
63
80
 
64
81
  private
65
82
 
83
+ def current_user_context
84
+ Thread.current[:logister_user]
85
+ end
86
+
66
87
  def build_payload(event_type:, level:, message:, fingerprint:, context:)
67
88
  {
68
89
  event_type: event_type,
@@ -118,7 +139,36 @@ module Logister
118
139
  end
119
140
 
120
141
  def default_fingerprint(exception)
121
- Digest::SHA256.hexdigest("#{exception.class}|#{exception.message}")[0, 32]
142
+ # Prefer class + first backtrace location so that errors with dynamic
143
+ # values in their message (e.g. "Couldn't find User with 'id'=42") still
144
+ # group together across different IDs / UUIDs.
145
+ 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
149
+
150
+ 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.
153
+ scrubbed = scrub_dynamic_values(exception.message.to_s)
154
+ Digest::SHA256.hexdigest("#{exception.class}|#{scrubbed}")[0, 32]
155
+ else
156
+ Digest::SHA256.hexdigest("#{exception.class}|#{location}")[0, 32]
157
+ end
158
+ end
159
+
160
+ # Strip values that tend to vary per-occurrence but carry no grouping signal:
161
+ # - numeric IDs: id=42, 'id'=42, id: 42
162
+ # - UUIDs
163
+ # - hex digests (≥8 hex chars)
164
+ # - quoted string values in ActiveRecord-style messages
165
+ def scrub_dynamic_values(message)
166
+ 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+/, '?')
122
172
  end
123
173
  end
124
174
  end
@@ -0,0 +1,57 @@
1
+ module Logister
2
+ class SqlSubscriber
3
+ IGNORED_SQL_NAMES = %w[SCHEMA TRANSACTION].freeze
4
+
5
+ class << self
6
+ def install!
7
+ return if @installed
8
+
9
+ ActiveSupport::Notifications.subscribe('sql.active_record') do |name, started, finished, _id, payload|
10
+ handle_sql_event(name, started, finished, payload)
11
+ end
12
+
13
+ @installed = true
14
+ end
15
+
16
+ private
17
+
18
+ def handle_sql_event(_name, started, finished, payload)
19
+ config = Logister.configuration
20
+ return unless config.capture_db_metrics
21
+ return if payload[:cached]
22
+ return if IGNORED_SQL_NAMES.include?(payload[:name].to_s)
23
+
24
+ duration_ms = (finished - started) * 1000.0
25
+ return if duration_ms < config.db_metric_min_duration_ms.to_f
26
+ return if sampled_out?(config.db_metric_sample_rate)
27
+
28
+ level = duration_ms >= 500 ? 'warn' : 'info'
29
+
30
+ Logister.report_metric(
31
+ message: 'db.query',
32
+ level: level,
33
+ context: {
34
+ 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
39
+ },
40
+ tags: {
41
+ category: 'database'
42
+ }
43
+ )
44
+ rescue StandardError => e
45
+ config.logger.warn("logister sql subscriber failed: #{e.class} #{e.message}")
46
+ end
47
+
48
+ def sampled_out?(sample_rate)
49
+ rate = sample_rate.to_f
50
+ return true if rate <= 0.0
51
+ return false if rate >= 1.0
52
+
53
+ rand > rate
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,3 +1,3 @@
1
1
  module Logister
2
- VERSION = '0.1.1'
2
+ VERSION = '0.2.0'
3
3
  end
data/lib/logister.rb CHANGED
@@ -3,6 +3,7 @@ require_relative 'logister/configuration'
3
3
  require_relative 'logister/client'
4
4
  require_relative 'logister/reporter'
5
5
  require_relative 'logister/middleware'
6
+ require_relative 'logister/sql_subscriber'
6
7
 
7
8
  module Logister
8
9
  class << self
@@ -27,6 +28,14 @@ module Logister
27
28
  reporter.report_metric(**kwargs)
28
29
  end
29
30
 
31
+ def set_user(id: nil, email: nil, name: nil, **extra)
32
+ reporter.set_user(id: id, email: email, name: name, **extra)
33
+ end
34
+
35
+ def clear_user
36
+ reporter.clear_user
37
+ end
38
+
30
39
  def flush(timeout: 2)
31
40
  reporter.flush(timeout: timeout)
32
41
  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.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Logister
@@ -55,6 +55,7 @@ files:
55
55
  - lib/logister/middleware.rb
56
56
  - lib/logister/railtie.rb
57
57
  - lib/logister/reporter.rb
58
+ - lib/logister/sql_subscriber.rb
58
59
  - lib/logister/version.rb
59
60
  - logister-ruby.gemspec
60
61
  homepage: https://logister.org