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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +177 -0
- data/lib/closeyourit/background_worker.rb +45 -0
- data/lib/closeyourit/breadcrumb.rb +35 -0
- data/lib/closeyourit/breadcrumb_buffer.rb +32 -0
- data/lib/closeyourit/client.rb +30 -0
- data/lib/closeyourit/configuration.rb +168 -0
- data/lib/closeyourit/event.rb +57 -0
- data/lib/closeyourit/events/error_event.rb +94 -0
- data/lib/closeyourit/events/message_event.rb +35 -0
- data/lib/closeyourit/events/slow_method_event.rb +61 -0
- data/lib/closeyourit/events/slow_query_event.rb +61 -0
- data/lib/closeyourit/instrumenter.rb +29 -0
- data/lib/closeyourit/monitor.rb +34 -0
- data/lib/closeyourit/rails/active_job_extension.rb +42 -0
- data/lib/closeyourit/rails/capture_exceptions.rb +21 -0
- data/lib/closeyourit/rails/error_subscriber.rb +30 -0
- data/lib/closeyourit/rails/query_source.rb +17 -0
- data/lib/closeyourit/rails/railtie.rb +74 -0
- data/lib/closeyourit/rails/request_context.rb +75 -0
- data/lib/closeyourit/scope.rb +115 -0
- data/lib/closeyourit/scrubber.rb +71 -0
- data/lib/closeyourit/sidekiq/error_handler.rb +25 -0
- data/lib/closeyourit/subscribers/slow_query.rb +55 -0
- data/lib/closeyourit/transport.rb +47 -0
- data/lib/closeyourit/version.rb +5 -0
- data/lib/closeyourit-ruby.rb +193 -0
- metadata +84 -0
|
@@ -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
|