closeyourit-ruby 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.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module CloseYourIt
6
+ # Messaggio diagnostico esplicito (`CloseYourIt.capture_message`) nel formato evento Sentry
7
+ # (`message.formatted` + level). Fonde lo Scope corrente come ErrorEvent.
8
+ class MessageEvent < Event
9
+ def initialize(message, level:, configuration:)
10
+ super(configuration)
11
+ @message = message
12
+ @level = level
13
+ end
14
+
15
+ def to_h
16
+ base = compact(
17
+ "event_id" => SecureRandom.uuid.delete("-"),
18
+ "timestamp" => @occurred_at,
19
+ "platform" => "ruby",
20
+ "level" => @level,
21
+ "environment" => environment,
22
+ "release" => @configuration.release,
23
+ "server_name" => server_name,
24
+ "message" => { "formatted" => @message },
25
+ "contexts" => { "runtime" => { "name" => "ruby", "version" => RUBY_VERSION } },
26
+ "sdk" => sdk
27
+ )
28
+ deep_merge(base, CloseYourIt::Scope.current.to_event_hash)
29
+ end
30
+
31
+ def ingest_path(project_id)
32
+ "/api/v1/projects/#{project_id}/events"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../event"
5
+ require_relative "../scrubber"
6
+
7
+ module CloseYourIt
8
+ # Payload `kind=slow_method` per la pipeline metriche. Di default solo label + durata + posizione.
9
+ # Gli argomenti del metodo sono inviati SOLO se `capture_method_arguments` (opt-in, default OFF):
10
+ # posizionali per indice, kwargs per nome (scrub della chiave sensibile), valore via `inspect`
11
+ # troncato per sicurezza JSON. Vedi PDR §9.
12
+ class SlowMethodEvent < Event
13
+ def initialize(label, duration_ms, location, configuration, args: nil, kwargs: nil)
14
+ super(configuration)
15
+ @label = label
16
+ @duration_ms = duration_ms
17
+ @location = location
18
+ @args = args
19
+ @kwargs = kwargs
20
+ @scrubber = Scrubber.new(configuration)
21
+ end
22
+
23
+ def to_h
24
+ compact(
25
+ "kind" => "slow_method",
26
+ "sample_id" => SecureRandom.uuid,
27
+ "duration_ms" => @duration_ms.round(2),
28
+ "occurred_at" => @occurred_at,
29
+ "environment" => environment,
30
+ "label" => @label,
31
+ "file" => @location&.path,
32
+ "lineno" => @location&.lineno,
33
+ "arguments" => arguments,
34
+ "sdk" => sdk
35
+ )
36
+ end
37
+
38
+ def ingest_path(project_id)
39
+ "/api/v1/projects/#{project_id}/metrics"
40
+ end
41
+
42
+ private
43
+
44
+ # Argomenti — SOLO se capture_method_arguments (opt-in). Posizionali per indice; kwargs per nome con
45
+ # scrub della chiave sensibile (denylist password/token/…). Valore = inspect troncato (JSON-safe).
46
+ def arguments
47
+ return nil unless @configuration.capture_method_arguments
48
+
49
+ list = Array(@args).each_with_index.map { |arg, i| { "name" => "arg#{i + 1}", "value" => safe_value(arg) } }
50
+ (@kwargs || {}).each do |key, value|
51
+ list << { "name" => key.to_s, "value" => @scrubber.filter_value(key, safe_value(value)) }
52
+ end
53
+ list
54
+ end
55
+
56
+ def safe_value(value)
57
+ inspected = value.inspect.to_s
58
+ inspected.length > 120 ? "#{inspected[0, 117]}…" : inspected
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../event"
5
+ require_relative "../scrubber"
6
+
7
+ module CloseYourIt
8
+ # Payload `kind=slow_query` per la pipeline metriche (`/api/v1/projects/:id/metrics`).
9
+ # Lo SQL è offuscato (binds esclusi) — vedi PDR §9.
10
+ class SlowQueryEvent < Event
11
+ def initialize(payload, duration_ms, configuration)
12
+ super(configuration)
13
+ @payload = payload
14
+ @duration_ms = duration_ms
15
+ @scrubber = Scrubber.new(configuration)
16
+ end
17
+
18
+ def to_h
19
+ compact(
20
+ "kind" => "slow_query",
21
+ "sample_id" => SecureRandom.uuid,
22
+ "duration_ms" => @duration_ms.round(2),
23
+ "occurred_at" => @occurred_at,
24
+ "environment" => environment,
25
+ "sql" => @scrubber.obfuscate_sql(@payload[:sql]),
26
+ "name" => @payload[:name],
27
+ "cached" => @payload.fetch(:cached, false),
28
+ "db_system" => db_system,
29
+ "source" => @payload[:source],
30
+ "bindings" => bindings,
31
+ "sdk" => sdk
32
+ )
33
+ end
34
+
35
+ def ingest_path(project_id)
36
+ "/api/v1/projects/#{project_id}/metrics"
37
+ end
38
+
39
+ private
40
+
41
+ def db_system
42
+ connection = @payload[:connection]
43
+ return nil unless connection.respond_to?(:adapter_name)
44
+
45
+ connection.adapter_name.to_s.downcase
46
+ end
47
+
48
+ # Valori dei bind — SOLO se capture_query_bindings (opt-in, default OFF). Scrub per nome colonna
49
+ # (denylist password/token/…); il valore è reso come stringa per sicurezza JSON.
50
+ def bindings
51
+ return nil unless @configuration.capture_query_bindings
52
+
53
+ binds = Array(@payload[:binds])
54
+ values = Array(@payload[:type_casted_binds])
55
+ binds.each_with_index.map do |attr, i|
56
+ name = attr.respond_to?(:name) ? attr.name.to_s : attr.to_s
57
+ { "name" => name, "value" => @scrubber.filter_value(name, values[i]).to_s }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "events/slow_method_event"
4
+
5
+ module CloseYourIt
6
+ # Cronometra blocchi/metodi con `CLOCK_MONOTONIC` e invia un `slow_method` se la durata supera la
7
+ # soglia. Gli argomenti sono inviati solo se `capture_method_arguments` (opt-in) — vedi SlowMethodEvent.
8
+ module Instrumenter
9
+ module_function
10
+
11
+ def measure(label, args: nil, kwargs: nil)
12
+ location = caller_locations(1, 1)&.first
13
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ yield
15
+ ensure
16
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
17
+ report(label, duration_ms, location, args: args, kwargs: kwargs)
18
+ end
19
+
20
+ def report(label, duration_ms, location = nil, args: nil, kwargs: nil)
21
+ config = CloseYourIt.configuration
22
+ return if duration_ms < config.slow_method_threshold_ms
23
+
24
+ CloseYourIt.capture_event(
25
+ SlowMethodEvent.new(label, duration_ms, location, config, args: args, kwargs: kwargs)
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "instrumenter"
4
+
5
+ module CloseYourIt
6
+ # Macro per strumentare automaticamente un metodo:
7
+ #
8
+ # class Report
9
+ # include CloseYourIt::Monitor
10
+ # def generate(...) = ...
11
+ # monitor :generate
12
+ # end
13
+ #
14
+ # Wrappa il metodo via `Module#prepend` cronometrandolo, senza cambiarne firma/risultato.
15
+ module Monitor
16
+ def self.included(base)
17
+ base.extend(ClassMethods)
18
+ end
19
+
20
+ module ClassMethods
21
+ def monitor(method_name, label: nil)
22
+ wrapper = Module.new do
23
+ define_method(method_name) do |*args, **kwargs, &block|
24
+ measured_label = label || "#{self.class}##{method_name}"
25
+ CloseYourIt::Instrumenter.measure(measured_label, args: args, kwargs: kwargs) do
26
+ super(*args, **kwargs, &block)
27
+ end
28
+ end
29
+ end
30
+ prepend(wrapper)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Rails
5
+ # Incluso in ActiveJob::Base (via railtie `on_load(:active_job)`): cattura gli errori dei job
6
+ # (oggi persi) con il contesto del job, poi ri-solleva. La logica vive in `.monitor` per essere
7
+ # testabile senza ActiveSupport/ActiveJob.
8
+ module ActiveJobExtension
9
+ def self.included(base)
10
+ base.around_perform do |job, block|
11
+ CloseYourIt::Rails::ActiveJobExtension.monitor(job) { block.call }
12
+ end
13
+ end
14
+
15
+ # Esegue il job arricchendo lo scope con tag/context; cattura l'errore (handled:false) e
16
+ # ri-solleva; resetta lo scope a fine job (no bleed tra job sullo stesso thread).
17
+ def self.monitor(job)
18
+ return yield unless CloseYourIt.configuration.report_active_job_errors
19
+
20
+ begin
21
+ apply_job_scope(job)
22
+ yield
23
+ rescue Exception => e # rubocop:disable Lint/RescueException
24
+ CloseYourIt.capture_exception(e, handled: false)
25
+ raise
26
+ ensure
27
+ CloseYourIt::Scope.reset!
28
+ end
29
+ end
30
+
31
+ def self.apply_job_scope(job)
32
+ CloseYourIt.set_tag("job.class", job.class.name)
33
+ CloseYourIt.set_tag("job.queue", job.queue_name) if job.respond_to?(:queue_name)
34
+
35
+ context = {}
36
+ context["job_id"] = job.job_id if job.respond_to?(:job_id)
37
+ context["executions"] = job.executions if job.respond_to?(:executions)
38
+ CloseYourIt.set_context("active_job", context) unless context.empty?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Rails
5
+ # Rack middleware: cattura le eccezioni non gestite, le invia a CloseYourIt
6
+ # e le **ri-solleva** (l'app continua a gestirle come prima). Rack puro, nessuna
7
+ # dipendenza da Rails → testabile in isolamento.
8
+ class CaptureExceptions
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ @app.call(env)
15
+ rescue Exception => e # rubocop:disable Lint/RescueException
16
+ CloseYourIt.capture_exception(e)
17
+ raise
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Rails
5
+ # Sottoscrittore di `ActiveSupport::ErrorReporter` (Rails 7+): cattura gli errori HANDLED
6
+ # riportati via `Rails.error.report`/`Rails.error.handle`. Gli unhandled passano già dal
7
+ # middleware Rack → la dedup (ivar sull'istanza) evita il doppio invio.
8
+ class ErrorSubscriber
9
+ SEVERITY_TO_LEVEL = { error: "error", warning: "warning", info: "info" }.freeze
10
+
11
+ # Sorgenti interne rumorose da non inoltrare (evita loop/duplicati).
12
+ IGNORED_SOURCES = %w[closeyourit].freeze
13
+
14
+ def report(error, handled:, severity:, context:, source: nil)
15
+ return if source && IGNORED_SOURCES.include?(source)
16
+ return unless CloseYourIt.configuration.capture_handled_errors
17
+
18
+ level = SEVERITY_TO_LEVEL.fetch(severity, "error")
19
+ contexts = context && !context.empty? ? { "rails_error" => stringify(context) } : nil
20
+ CloseYourIt.capture_exception(error, handled: handled, level: level, contexts: contexts)
21
+ end
22
+
23
+ private
24
+
25
+ def stringify(context)
26
+ context.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Rails
5
+ # Call-site applicativo di una query lenta (privacy-safe → sempre inviato): primo frame della
6
+ # backtrace ripulito da Rails.backtrace_cleaner (rimuove gem/framework, tiene il codice app),
7
+ # senza il suffisso ":in '...'". Es. "app/models/order.rb:42".
8
+ module QuerySource
9
+ def self.from_caller(backtrace = caller)
10
+ frame = ::Rails.backtrace_cleaner.clean(backtrace).first
11
+ return nil if frame.nil?
12
+
13
+ frame.split(":in ", 2).first
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capture_exceptions"
4
+ require_relative "request_context"
5
+ require_relative "active_job_extension"
6
+ require_relative "error_subscriber"
7
+ require_relative "../sidekiq/error_handler"
8
+ require_relative "query_source"
9
+ require_relative "../subscribers/slow_query"
10
+
11
+ module CloseYourIt
12
+ module Rails
13
+ # Aggancia il client a Rails: Rack middleware di cattura eccezioni +
14
+ # subscriber `sql.active_record` per le query lente.
15
+ class Railtie < ::Rails::Railtie
16
+ initializer "closeyourit.use_rack_middleware" do |app|
17
+ app.config.middleware.use CloseYourIt::Rails::CaptureExceptions
18
+ # RequestContext deve AVVOLGERE CaptureExceptions: lo scope dev'essere già popolato
19
+ # quando l'eccezione risale a CaptureExceptions.
20
+ app.config.middleware.insert_before(
21
+ CloseYourIt::Rails::CaptureExceptions,
22
+ CloseYourIt::Rails::RequestContext
23
+ )
24
+ end
25
+
26
+ initializer "closeyourit.subscribe_slow_queries" do
27
+ subscriber = CloseYourIt::Subscribers::SlowQuery.new
28
+
29
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
30
+ event = ActiveSupport::Notifications::Event.new(*args)
31
+ subscriber.record(
32
+ name: event.payload[:name],
33
+ duration_ms: event.duration,
34
+ sql: event.payload[:sql],
35
+ cached: event.payload.fetch(:cached, false),
36
+ connection: event.payload[:connection],
37
+ binds: event.payload[:binds],
38
+ type_casted_binds: event.payload[:type_casted_binds],
39
+ source: CloseYourIt::Rails::QuerySource.from_caller
40
+ )
41
+ subscriber.breadcrumb(
42
+ name: event.payload[:name],
43
+ sql: event.payload[:sql],
44
+ duration_ms: event.duration,
45
+ cached: event.payload.fetch(:cached, false)
46
+ )
47
+ end
48
+ end
49
+
50
+ # Cattura gli errori di ActiveJob/Solid Queue (around_perform).
51
+ initializer "closeyourit.active_job" do
52
+ ActiveSupport.on_load(:active_job) do
53
+ include CloseYourIt::Rails::ActiveJobExtension
54
+ end
55
+ end
56
+
57
+ # Cattura gli errori HANDLED riportati via Rails.error.report (Rails 7+).
58
+ initializer "closeyourit.error_reporter" do
59
+ if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
60
+ ::Rails.error.subscribe(CloseYourIt::Rails::ErrorSubscriber.new)
61
+ end
62
+ end
63
+
64
+ # Cattura gli errori dei job Sidekiq (solo se Sidekiq è presente).
65
+ initializer "closeyourit.sidekiq" do
66
+ if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
67
+ ::Sidekiq.configure_server do |sidekiq_config|
68
+ sidekiq_config.error_handlers << CloseYourIt::Sidekiq::ErrorHandler.new
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Rails
5
+ # Rack middleware: popola lo Scope con il contesto HTTP della richiesta (method/url/header)
6
+ # così l'evento d'errore catturato a valle sa "in quale pagina" è capitato. Deve avvolgere
7
+ # `CaptureExceptions` (insert_before) per essere già popolato quando l'eccezione risale.
8
+ # Rack puro (legge `env`, nessuna dipendenza da Rails) → testabile in isolamento.
9
+ class RequestContext
10
+ # Header con prefisso non-HTTP_ in env Rack.
11
+ RAW_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH].freeze
12
+
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ CloseYourIt::Scope.current.request = build_request(env) if active?
19
+ @app.call(env)
20
+ ensure
21
+ CloseYourIt::Scope.reset!
22
+ end
23
+
24
+ private
25
+
26
+ def active?
27
+ CloseYourIt.enabled? && CloseYourIt.configuration.capture_request
28
+ rescue StandardError
29
+ false
30
+ end
31
+
32
+ # Forma `request` Sentry. URL senza query string; header solo dall'allowlist (mai
33
+ # Authorization/Cookie). query_string + IP solo con send_pii (opt-in).
34
+ def build_request(env)
35
+ request = {
36
+ "method" => env["REQUEST_METHOD"],
37
+ "url" => build_url(env),
38
+ "headers" => allowed_headers(env)
39
+ }.reject { |_key, value| value.nil? }
40
+
41
+ if CloseYourIt.configuration.send_pii
42
+ query = env["QUERY_STRING"]
43
+ request["query_string"] = query if query && !query.empty?
44
+ ip = env["REMOTE_ADDR"]
45
+ request["env"] = { "REMOTE_ADDR" => ip } if ip
46
+ end
47
+
48
+ request
49
+ rescue StandardError
50
+ # La telemetria non deve mai disturbare la richiesta ospite.
51
+ nil
52
+ end
53
+
54
+ def build_url(env)
55
+ scheme = env["rack.url_scheme"] || "http"
56
+ host = env["HTTP_HOST"] || env["SERVER_NAME"]
57
+ return nil if host.nil?
58
+
59
+ "#{scheme}://#{host}#{env["PATH_INFO"]}"
60
+ end
61
+
62
+ def allowed_headers(env)
63
+ CloseYourIt.configuration.request_header_allowlist.each_with_object({}) do |name, acc|
64
+ value = env[header_env_key(name)]
65
+ acc[name] = value if value
66
+ end
67
+ end
68
+
69
+ def header_env_key(name)
70
+ upcased = name.upcase.tr("-", "_")
71
+ RAW_HEADERS.include?(upcased) ? upcased : "HTTP_#{upcased}"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "breadcrumb_buffer"
4
+
5
+ module CloseYourIt
6
+ # Contesto per-richiesta (o per-job) isolato per execution-context (Fiber storage):
7
+ # user/tags/extra/contexts/request. Letto da ErrorEvent#to_h sul thread chiamante (sincrono)
8
+ # → il worker di invio non lo vede mai e lo scope non cola tra richieste.
9
+ class Scope
10
+ STORAGE_KEY = :__closeyourit_scope
11
+
12
+ class << self
13
+ # Scope dell'execution-context corrente. Usa `ActiveSupport::IsolatedExecutionState` quando
14
+ # presente (rispetta isolation_level: thread su Puma, fiber su Falcon), altrimenti
15
+ # `Thread.current` (thread-local puro, NON ereditato dai thread figli → niente bleed).
16
+ def current
17
+ store[STORAGE_KEY] ||= new
18
+ end
19
+
20
+ # Azzera lo scope corrente — chiamato in `ensure` da middleware e job (su Puma il
21
+ # thread è riusato: senza reset lo scope colerebbe nella richiesta successiva).
22
+ def reset!
23
+ store[STORAGE_KEY] = nil
24
+ end
25
+
26
+ private
27
+
28
+ def store
29
+ if defined?(::ActiveSupport::IsolatedExecutionState)
30
+ ::ActiveSupport::IsolatedExecutionState
31
+ else
32
+ ::Thread.current
33
+ end
34
+ end
35
+ end
36
+
37
+ attr_accessor :request
38
+ attr_reader :user, :tags, :extra, :contexts, :breadcrumbs
39
+
40
+ def initialize
41
+ clear
42
+ end
43
+
44
+ def set_user(attributes)
45
+ @user.merge!(stringify_keys(attributes))
46
+ end
47
+
48
+ def set_tag(key, value)
49
+ @tags[key.to_s] = value
50
+ end
51
+
52
+ def set_tags(attributes)
53
+ attributes.each { |key, value| set_tag(key, value) }
54
+ end
55
+
56
+ def set_context(key, attributes)
57
+ @contexts[key.to_s] = stringify_keys(attributes)
58
+ end
59
+
60
+ def set_extra(key, value)
61
+ @extra[key.to_s] = value
62
+ end
63
+
64
+ def add_breadcrumb(breadcrumb)
65
+ @breadcrumbs.add(breadcrumb)
66
+ end
67
+
68
+ def clear
69
+ @user = {}
70
+ @tags = {}
71
+ @extra = {}
72
+ @contexts = {}
73
+ @request = nil
74
+ @breadcrumbs = BreadcrumbBuffer.new(CloseYourIt.configuration.max_breadcrumbs)
75
+ end
76
+
77
+ # Sottoinsieme non vuoto in forma evento Sentry (user/tags/extra/contexts/request),
78
+ # fuso nel payload da ErrorEvent#to_h.
79
+ def to_event_hash
80
+ {
81
+ "user" => serialize_user,
82
+ "tags" => presence(@tags),
83
+ "extra" => presence(@extra),
84
+ "contexts" => presence(@contexts),
85
+ "request" => @request,
86
+ "breadcrumbs" => breadcrumbs_payload
87
+ }.reject { |_key, value| value.nil? }
88
+ end
89
+
90
+ private
91
+
92
+ # `user.id` sempre; email/ip_address/username solo con `send_pii` (il backend li strippa
93
+ # comunque — difesa in profondità).
94
+ def serialize_user
95
+ return nil if @user.empty?
96
+ return @user if CloseYourIt.configuration.send_pii
97
+
98
+ presence(@user.slice("id"))
99
+ end
100
+
101
+ def breadcrumbs_payload
102
+ return nil if @breadcrumbs.empty?
103
+
104
+ { "values" => @breadcrumbs.to_a }
105
+ end
106
+
107
+ def presence(hash)
108
+ hash.empty? ? nil : hash
109
+ end
110
+
111
+ def stringify_keys(attributes)
112
+ attributes.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ # Rimozione PII dai payload: filtro chiavi sensibili, normalizzazione SQL, scrub messaggi.
5
+ # Privacy-by-default — vedi PDR §9.
6
+ class Scrubber
7
+ FILTERED = "[FILTERED]"
8
+
9
+ # Token di chiavi sempre redatti (match per sottostringa, normalizzato).
10
+ DENYLIST = %w[
11
+ password passwd secret token api_key apikey authorization
12
+ cookie set-cookie csrf credit_card card cvv ssn iban
13
+ ].freeze
14
+
15
+ STRING_LITERAL = /'(?:[^']|'')*'/
16
+ NUMERIC_LITERAL = /\b\d+(?:\.\d+)?\b/
17
+
18
+ def initialize(configuration)
19
+ @configuration = configuration
20
+ end
21
+
22
+ # Filtra ricorsivamente Hash/Array sostituendo i valori delle chiavi sensibili.
23
+ def filter_params(value)
24
+ case value
25
+ when Hash
26
+ value.each_with_object({}) do |(key, val), acc|
27
+ acc[key] = sensitive_key?(key) ? FILTERED : filter_params(val)
28
+ end
29
+ when Array
30
+ value.map { |item| filter_params(item) }
31
+ else
32
+ value
33
+ end
34
+ end
35
+
36
+ # Maschera i literal (stringa/numerici) nello SQL, preservando la struttura.
37
+ def obfuscate_sql(sql)
38
+ return sql if sql.nil? || !@configuration.obfuscate_sql
39
+
40
+ sql.to_s.gsub(STRING_LITERAL, "?").gsub(NUMERIC_LITERAL, "?")
41
+ end
42
+
43
+ def scrub_message(message)
44
+ return message if message.nil?
45
+
46
+ @configuration.scrub_message_patterns.reduce(message.to_s) do |acc, pattern|
47
+ acc.gsub(pattern, FILTERED)
48
+ end
49
+ end
50
+
51
+ # Valore di un singolo bind/argomento: redatto se il nome (colonna/parametro) è sensibile.
52
+ def filter_value(key, value)
53
+ sensitive_key?(key) ? FILTERED : value
54
+ end
55
+
56
+ private
57
+
58
+ def sensitive_key?(key)
59
+ normalized = normalize(key)
60
+ return true if DENYLIST.any? { |token| normalized.include?(normalize(token)) }
61
+
62
+ @configuration.filter_parameters.any? do |param|
63
+ param.is_a?(Regexp) ? param.match?(key.to_s) : normalized.include?(normalize(param))
64
+ end
65
+ end
66
+
67
+ def normalize(value)
68
+ value.to_s.downcase.gsub(/[^a-z0-9]/, "")
69
+ end
70
+ end
71
+ end