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.
@@ -1,74 +1,202 @@
1
- require 'socket'
1
+ require_relative "context_helpers"
2
+ require_relative "context_store"
2
3
 
3
4
  module Logister
4
5
  class Middleware
6
+ FILTERED_HEADER_PLACEHOLDER = "[FILTERED]".freeze
7
+ SENSITIVE_HEADERS = %w[authorization cookie set-cookie x-api-key x-csrf-token].freeze
8
+
5
9
  def initialize(app)
6
10
  @app = app
7
11
  end
8
12
 
9
13
  def call(env)
14
+ Logister::ContextStore.reset_request_scope!
15
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
16
  @app.call(env)
11
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
+
12
21
  Logister.report_error(
13
22
  e,
14
- context: {
15
- request: build_request_context(env),
16
- app: build_app_context
17
- }
23
+ context: request_context
18
24
  )
19
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!
20
30
  end
21
31
 
22
32
  private
23
33
 
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)
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)
36
129
  end
37
130
 
38
- ctx.compact
131
+ headers.sort.to_h
39
132
  end
40
133
 
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
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
48
144
  end
49
145
 
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
146
+ def filter_header_value(name, value)
147
+ return FILTERED_HEADER_PLACEHOLDER if SENSITIVE_HEADERS.include?(name.to_s.downcase)
148
+
149
+ value
54
150
  end
55
151
 
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
152
+ def current_user(env)
153
+ controller = env["action_controller.instance"]
154
+ return nil unless controller
59
155
 
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
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)
63
160
  end
64
161
  rescue StandardError
65
- {}
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
66
177
  end
67
178
 
68
- def hostname
69
- 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)
70
183
  rescue StandardError
71
- '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
72
200
  end
73
201
  end
74
202
  end
@@ -1,4 +1,5 @@
1
1
  require 'rails/railtie'
2
+ require_relative 'active_job_reporter'
2
3
 
3
4
  module Logister
4
5
  class Railtie < Rails::Railtie
@@ -24,6 +25,13 @@ module Logister
24
25
  copy_setting(app, config, :capture_db_metrics)
25
26
  copy_setting(app, config, :db_metric_min_duration_ms)
26
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)
27
35
  end
28
36
  end
29
37
 
@@ -35,6 +43,16 @@ module Logister
35
43
  Logister::SqlSubscriber.install!
36
44
  end
37
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
+
38
56
  private
39
57
 
40
58
  def copy_setting(app, config, key)
@@ -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
@@ -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,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.2'
3
5
  end