closeyourit-ruby 0.3.3 → 0.4.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: 82202d393845e74a379b6470a2f5f1c9e093d70840669266fb86624edd326c48
4
- data.tar.gz: 7a302acfe53c9e0eb866ba407e9133bb63681c3d365126abdbf55368527ab7e4
3
+ metadata.gz: f8d2868773842804dc2c90f163ea4766ea748c59edf8adaa956f257827391e8c
4
+ data.tar.gz: 7fe183c1a6c64226f341b09c7da4cd37bf58bb75409b9afc20c223efc55ffe1f
5
5
  SHA512:
6
- metadata.gz: b85ebd77af24ee4aaa41ab447ba434be2ac3892c0ba4c7b09cf5a39069aca91ddb649a179ef621d72c263449452dcb5942c82df5449cd17e37a3494c374b8ed0
7
- data.tar.gz: 037b9982aad55978b4c124d0751da800983327d18d8786228d8bbd00233f913a0e6ef8dd7e741c06307f11ed425ba2e37b8eae9dc985fba7c2067a9cbafda7fa
6
+ metadata.gz: fc0267a4f038cd2ab3f6e0af119cacbdb368cdce3f4d07d2a01ea1b8b5f2d2c4f4753fed6d5dcc0b71c5826868c7557eab614d7b0f37f953fd586aff01147c1b
7
+ data.tar.gz: 0b6e57599d21d07d4d2552331aa14d17bed514c7ed0733881d4a7ebd850051ca1271137fac7147f1e2d12c74c69e6e5e265017b256c36abf06ffd6342b458350
data/README.md CHANGED
@@ -26,12 +26,12 @@ Caratteristiche:
26
26
 
27
27
  ## Installazione
28
28
 
29
- Uso interno → install via git o path (no RubyGems):
29
+ Pubblicata su [RubyGems](https://rubygems.org/gems/closeyourit-ruby):
30
30
 
31
31
  ```ruby
32
32
  # Gemfile del progetto da monitorare
33
- gem "closeyourit-ruby", git: "https://github.com/bussolabs/closeyourit-ruby"
34
- # in sviluppo locale:
33
+ gem "closeyourit-ruby"
34
+ # in sviluppo locale, dal checkout:
35
35
  # gem "closeyourit-ruby", path: "../closeyourit-ruby"
36
36
  ```
37
37
 
@@ -20,11 +20,15 @@ module CloseYourIt
20
20
  :slow_query_threshold_ms, :slow_method_threshold_ms,
21
21
  :send_pii, :obfuscate_sql, :send_server_name,
22
22
  :capture_query_bindings, :capture_method_arguments,
23
- :capture_request, :request_header_allowlist,
23
+ :capture_request, :request_header_allowlist, :context_lines,
24
+ :capture_request_body,
24
25
  :breadcrumbs_enabled, :max_breadcrumbs, :sample_rate,
25
26
  :capture_handled_errors, :report_active_job_errors,
26
27
  :logs_enabled, :logs_sample_rate, :logs_batch_size, :logs_flush_interval,
27
- :capture_rails_logs, :logs_min_level
28
+ :capture_rails_logs, :logs_min_level,
29
+ :detect_performance_issues, :n_plus_one_threshold, :query_count_threshold,
30
+ :query_time_threshold_ms, :slow_request_threshold_ms, :slow_external_threshold_ms,
31
+ :capture_external_http
28
32
  attr_writer :release
29
33
  attr_reader :excluded_exceptions, :filter_parameters, :scrub_message_patterns
30
34
 
@@ -52,6 +56,14 @@ module CloseYourIt
52
56
  @capture_request = true
53
57
  @request_header_allowlist = DEFAULT_REQUEST_HEADER_ALLOWLIST.dup
54
58
 
59
+ # Righe di sorgente attorno a ogni frame dello stacktrace (pre/context/post). 0 = disattivo.
60
+ @context_lines = 3
61
+
62
+ # Params del body della richiesta nell'evento (`request.data`), estratti LAZY solo quando
63
+ # l'errore accade, sanitizzati e scrubbati (denylist + filter_parameters). Il backend
64
+ # ri-scruba difensivamente. Upload → placeholder, cap 64 KB.
65
+ @capture_request_body = true
66
+
55
67
  # Breadcrumbs: cronologia (query offuscate, eventi custom) allegata all'errore.
56
68
  @breadcrumbs_enabled = true
57
69
  @max_breadcrumbs = 100
@@ -76,6 +88,17 @@ module CloseYourIt
76
88
  @capture_rails_logs = false
77
89
  @logs_min_level = :info
78
90
 
91
+ # Performance issue detection (verdetti aggregati: N+1, slow request, HTTP esterne lente).
92
+ # OPT-IN, default OFF: profila OGNI query della richiesta → overhead non trascurabile, va attivato
93
+ # consapevolmente per-app. Le soglie sono conservative (poco rumore). Vedi Performance::Rollup.
94
+ @detect_performance_issues = false
95
+ @n_plus_one_threshold = 10 # stesso fingerprint+call-site eseguito > N volte in una richiesta
96
+ @query_count_threshold = 100 # troppe query totali in una richiesta
97
+ @query_time_threshold_ms = 500 # tempo DB totale per richiesta oltre cui = high_query_count
98
+ @slow_request_threshold_ms = 1000 # durata totale della richiesta
99
+ @slow_external_threshold_ms = 1000 # singola chiamata HTTP esterna
100
+ @capture_external_http = true # strumenta Net::HTTP (solo se detect_performance_issues)
101
+
79
102
  @filter_parameters = []
80
103
  @scrub_message_patterns = []
81
104
  end
@@ -4,6 +4,7 @@ require "securerandom"
4
4
  require "socket"
5
5
  require_relative "../event"
6
6
  require_relative "../scrubber"
7
+ require_relative "../line_cache"
7
8
 
8
9
  module CloseYourIt
9
10
  # Trasforma un'eccezione Ruby nel **payload evento Sentry** che il backend CloseYourIt ingerisce
@@ -72,21 +73,35 @@ module CloseYourIt
72
73
  }
73
74
  end
74
75
 
75
- # Sentry-style: frame più recente per ultimo; chiavi filename/function/lineno/in_app/abs_path.
76
+ # Sentry-style: frame più recente per ultimo; chiavi filename/function/lineno/in_app/abs_path
77
+ # + snippet di sorgente (pre_context/context_line/post_context) quando il file è leggibile.
76
78
  def frames(locations)
77
79
  return [] if locations.nil?
78
80
 
81
+ context_lines = @configuration.context_lines.to_i
79
82
  locations.reverse.map do |loc|
80
- {
83
+ frame = {
81
84
  "filename" => loc.path,
82
85
  "abs_path" => loc.path,
83
86
  "function" => loc.label,
84
87
  "lineno" => loc.lineno,
85
88
  "in_app" => in_app?(loc.path)
86
89
  }
90
+ add_context!(frame, loc.path, loc.lineno, context_lines) if context_lines.positive?
91
+ frame
87
92
  end
88
93
  end
89
94
 
95
+ def add_context!(frame, path, lineno, count)
96
+ lines = LineCache.lines(path)
97
+ return if lines.nil? || lineno.nil? || lineno < 1 || lineno > lines.size
98
+
99
+ index = lineno - 1
100
+ frame["pre_context"] = lines[[ index - count, 0 ].max...index]
101
+ frame["context_line"] = lines[index]
102
+ frame["post_context"] = lines[index + 1, count] || []
103
+ end
104
+
90
105
  def in_app?(path)
91
106
  return false if path.nil?
92
107
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../event"
5
+
6
+ module CloseYourIt
7
+ # Payload `kind=performance_issue` per la pipeline metriche (`/api/v1/projects/:id/metrics`).
8
+ # È un VERDETTO aggregato (N+1, slow request, slow external HTTP), non una metrica grezza:
9
+ # `subtype` lo qualifica, `trace_id` lo correla a log/errori della stessa richiesta. Lo SQL è già
10
+ # offuscato (è il fingerprint del profilo). I campi nil vengono omessi (slow_request non ha sql).
11
+ class PerformanceIssueEvent < Event
12
+ def initialize(attrs, configuration)
13
+ super(configuration)
14
+ @attrs = attrs
15
+ end
16
+
17
+ def to_h
18
+ compact(
19
+ "kind" => "performance_issue",
20
+ "subtype" => @attrs[:subtype],
21
+ "sample_id" => SecureRandom.uuid,
22
+ "duration_ms" => @attrs[:duration_ms]&.round(2),
23
+ "occurred_at" => @occurred_at,
24
+ "environment" => environment,
25
+ "trace_id" => @attrs[:trace_id],
26
+ "sql" => @attrs[:sql],
27
+ "source" => @attrs[:source],
28
+ "route" => @attrs[:route],
29
+ "http_host" => @attrs[:http_host],
30
+ "http_url" => @attrs[:http_url],
31
+ "query_count" => @attrs[:query_count],
32
+ "total_query_time_ms" => @attrs[:total_query_time_ms]&.round(2),
33
+ "count_in_request" => @attrs[:count_in_request],
34
+ "sdk" => sdk
35
+ )
36
+ end
37
+
38
+ def ingest_path(project_id)
39
+ "/api/v1/projects/#{project_id}/metrics"
40
+ end
41
+ end
42
+ end
@@ -3,6 +3,7 @@
3
3
  require "securerandom"
4
4
  require_relative "../event"
5
5
  require_relative "../scrubber"
6
+ require_relative "../scope"
6
7
 
7
8
  module CloseYourIt
8
9
  # Payload `kind=slow_query` per la pipeline metriche (`/api/v1/projects/:id/metrics`).
@@ -27,6 +28,7 @@ module CloseYourIt
27
28
  "cached" => @payload.fetch(:cached, false),
28
29
  "db_system" => db_system,
29
30
  "source" => @payload[:source],
31
+ "trace_id" => CloseYourIt::Scope.current.trace_id,
30
32
  "bindings" => bindings,
31
33
  "sdk" => sdk
32
34
  )
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ # Cache in-process delle righe dei file sorgente, per le context lines dei frame
5
+ # (pre_context/context_line/post_context). Bounded: oltre MAX_FILES si svuota per intero —
6
+ # semplice e sufficiente (i file di un'app in errore sono pochi e ricorrenti). Thread-safe.
7
+ module LineCache
8
+ MAX_FILES = 200
9
+
10
+ @cache = {}
11
+ @mutex = Mutex.new
12
+
13
+ class << self
14
+ # Righe del file (chomp-ate), oppure nil se non leggibile o path sintetico ("(eval)", "(irb)").
15
+ def lines(path)
16
+ return nil if path.nil? || path.empty? || path.start_with?("(")
17
+
18
+ @mutex.synchronize do
19
+ return @cache[path] if @cache.key?(path)
20
+
21
+ @cache.clear if @cache.size >= MAX_FILES
22
+ @cache[path] = read_lines(path)
23
+ end
24
+ end
25
+
26
+ def clear
27
+ @mutex.synchronize { @cache.clear }
28
+ end
29
+
30
+ private
31
+
32
+ def read_lines(path)
33
+ return nil unless File.readable?(path)
34
+
35
+ File.readlines(path, chomp: true)
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Performance
5
+ # Accumulatore per-richiesta (vive nello Scope, resettato a fine richiesta). Conta le query, le
6
+ # raggruppa per [fingerprint SQL offuscato, call-site] (il pattern prosopite per l'N+1) e tiene le
7
+ # chiamate HTTP esterne. Puro stato in memoria: il verdetto lo calcola Performance::Rollup.
8
+ class RequestProfile
9
+ # Guard di memoria: cap ai gruppi/chiamate distinti tracciati (il conteggio totale resta esatto).
10
+ MAX_GROUPS = 1000
11
+ MAX_EXTERNAL = 500
12
+
13
+ attr_reader :query_count, :total_query_time_ms, :query_groups, :external_calls
14
+
15
+ def initialize
16
+ @query_count = 0
17
+ @total_query_time_ms = 0.0
18
+ @query_groups = {}
19
+ @external_calls = []
20
+ end
21
+
22
+ # Una query non di sistema. Le query da cache non sono round-trip DB → non contano per l'N+1.
23
+ def add_query(fingerprint:, source:, duration_ms:, cached: false)
24
+ return if cached
25
+
26
+ ms = duration_ms.to_f
27
+ @query_count += 1
28
+ @total_query_time_ms += ms
29
+
30
+ key = "#{fingerprint}\n#{source}"
31
+ group = @query_groups[key]
32
+ if group
33
+ group[:count] += 1
34
+ group[:duration_ms] += ms
35
+ elsif @query_groups.size < MAX_GROUPS
36
+ @query_groups[key] = { count: 1, duration_ms: ms, sql: fingerprint, source: source }
37
+ end
38
+ end
39
+
40
+ def add_external(host:, path:, duration_ms:)
41
+ return if @external_calls.size >= MAX_EXTERNAL
42
+
43
+ @external_calls << { host: host, path: path, duration_ms: duration_ms.to_f }
44
+ end
45
+
46
+ def empty?
47
+ @query_count.zero? && @external_calls.empty?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../events/performance_issue_event"
4
+
5
+ module CloseYourIt
6
+ module Performance
7
+ # Trasforma un RequestProfile (+ durata/route della richiesta) in 0..N verdetti PerformanceIssueEvent.
8
+ # Le soglie vivono nella Configuration. Detection lato client; dedup/alert lato backend.
9
+ class Rollup
10
+ def self.call(...) = new(...).call
11
+
12
+ def initialize(profile:, configuration:, route: nil, request_duration_ms: nil, trace_id: nil)
13
+ @profile = profile
14
+ @config = configuration
15
+ @route = route
16
+ @request_duration_ms = request_duration_ms
17
+ @trace_id = trace_id
18
+ end
19
+
20
+ def call
21
+ events = []
22
+ events.concat(n_plus_one_events)
23
+ events << high_query_count_event if high_query_count?
24
+ events << slow_request_event if slow_request?
25
+ events.concat(slow_external_events)
26
+ events.compact
27
+ end
28
+
29
+ private
30
+
31
+ # Un verdetto per ogni gruppo [fingerprint, call-site] che ha girato più di n_plus_one_threshold volte.
32
+ def n_plus_one_events
33
+ @profile.query_groups.values.filter_map do |group|
34
+ next unless group[:count] > @config.n_plus_one_threshold
35
+
36
+ build(subtype: "n_plus_one", duration_ms: group[:duration_ms], sql: group[:sql],
37
+ source: group[:source], query_count: group[:count], count_in_request: group[:count])
38
+ end
39
+ end
40
+
41
+ def high_query_count?
42
+ (@config.query_count_threshold && @profile.query_count > @config.query_count_threshold) ||
43
+ (@config.query_time_threshold_ms && @profile.total_query_time_ms > @config.query_time_threshold_ms)
44
+ end
45
+
46
+ def high_query_count_event
47
+ build(subtype: "high_query_count", duration_ms: @profile.total_query_time_ms,
48
+ query_count: @profile.query_count, total_query_time_ms: @profile.total_query_time_ms,
49
+ route: @route)
50
+ end
51
+
52
+ def slow_request?
53
+ @request_duration_ms && @config.slow_request_threshold_ms &&
54
+ @request_duration_ms > @config.slow_request_threshold_ms
55
+ end
56
+
57
+ def slow_request_event
58
+ build(subtype: "slow_request", duration_ms: @request_duration_ms, route: @route,
59
+ query_count: @profile.query_count, total_query_time_ms: @profile.total_query_time_ms)
60
+ end
61
+
62
+ def slow_external_events
63
+ @profile.external_calls.filter_map do |call|
64
+ next unless call[:duration_ms] > @config.slow_external_threshold_ms
65
+
66
+ build(subtype: "slow_external_http", duration_ms: call[:duration_ms],
67
+ http_host: call[:host], http_url: call[:path])
68
+ end
69
+ end
70
+
71
+ def build(attrs)
72
+ PerformanceIssueEvent.new(attrs.merge(trace_id: @trace_id), @config)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../scope"
5
+
6
+ module CloseYourIt
7
+ module Rails
8
+ # Prepended a Net::HTTP: cronometra ogni chiamata esterna e la spinge nel RequestProfile dello
9
+ # Scope, così la finestra della richiesta può rilevare le HTTP esterne lente. Trasparente
10
+ # (restituisce la risposta originale) e difensivo (no-op se la telemetria è off; mai solleva per
11
+ # colpa del profiling). Esclude le chiamate verso l'endpoint CloseYourIt stesso (niente loop).
12
+ module NetHTTPPatch
13
+ def request(req, body = nil, &block)
14
+ config = CloseYourIt.configuration
15
+ return super unless config.detect_performance_issues && config.capture_external_http
16
+
17
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ begin
19
+ super
20
+ ensure
21
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
22
+ record_external(config, req, duration_ms)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def record_external(config, req, duration_ms)
29
+ host = address
30
+ return if host.nil? || own_endpoint?(config, host)
31
+
32
+ CloseYourIt::Scope.current.performance_profile.add_external(
33
+ host: host, path: templatize_path(req), duration_ms: duration_ms
34
+ )
35
+ rescue StandardError
36
+ # Il profiling non deve mai disturbare la chiamata ospite.
37
+ nil
38
+ end
39
+
40
+ def own_endpoint?(config, host)
41
+ endpoint = config.endpoint_url
42
+ return false if endpoint.nil?
43
+
44
+ URI.parse(endpoint).host == host
45
+ rescue URI::InvalidURIError
46
+ false
47
+ end
48
+
49
+ # Path senza query string, con uuid e run di ≥3 cifre → placeholder (stessa rotta = stessa
50
+ # signature). La soglia ≥3 cifre preserva le versioni API tipo "/v1" e templatizza gli id reali
51
+ # ("/v1/charges/ch_12345" → "/v1/charges/ch_<n>", "/users/123" → "/users/<n>").
52
+ def templatize_path(req)
53
+ path = req.respond_to?(:path) ? req.path.to_s : ""
54
+ path.split("?", 2).first.to_s
55
+ .gsub(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, "<uuid>")
56
+ .gsub(/\d{3,}/, "<n>")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -7,7 +7,9 @@ require_relative "error_subscriber"
7
7
  require_relative "../sidekiq/error_handler"
8
8
  require_relative "query_source"
9
9
  require_relative "log_broadcast"
10
+ require_relative "net_http_patch"
10
11
  require_relative "../subscribers/slow_query"
12
+ require_relative "../subscribers/request_performance"
11
13
 
12
14
  module CloseYourIt
13
15
  module Rails
@@ -45,9 +47,39 @@ module CloseYourIt
45
47
  duration_ms: event.duration,
46
48
  cached: event.payload.fetch(:cached, false)
47
49
  )
50
+ # Accumula la query nel profilo per-richiesta (detection N+1 a fine richiesta).
51
+ subscriber.profile(
52
+ name: event.payload[:name],
53
+ sql: event.payload[:sql],
54
+ duration_ms: event.duration,
55
+ cached: event.payload.fetch(:cached, false),
56
+ source: CloseYourIt::Rails::QuerySource.from_caller
57
+ )
58
+ end
59
+ end
60
+
61
+ # A fine richiesta: trasforma il profilo accumulato in verdetti performance_issue (N+1, slow
62
+ # request, HTTP esterne lente). Il subscriber è no-op se detect_performance_issues è OFF.
63
+ initializer "closeyourit.subscribe_request_performance" do
64
+ perf = CloseYourIt::Subscribers::RequestPerformance.new
65
+
66
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
67
+ event = ActiveSupport::Notifications::Event.new(*args)
68
+ payload = event.payload
69
+ perf.record(
70
+ route: "#{payload[:controller]}##{payload[:action]}",
71
+ duration_ms: event.duration
72
+ )
48
73
  end
49
74
  end
50
75
 
76
+ # Strumenta le chiamate HTTP esterne (Net::HTTP) per rilevare quelle lente nella finestra della
77
+ # richiesta. Il patch è no-op (chiama super) se la detection è OFF → overhead trascurabile.
78
+ initializer "closeyourit.instrument_net_http" do
79
+ require "net/http"
80
+ ::Net::HTTP.prepend(CloseYourIt::Rails::NetHTTPPatch) unless ::Net::HTTP.ancestors.include?(CloseYourIt::Rails::NetHTTPPatch)
81
+ end
82
+
51
83
  # Cattura gli errori di ActiveJob/Solid Queue (around_perform).
52
84
  initializer "closeyourit.active_job" do
53
85
  ActiveSupport.on_load(:active_job) do
@@ -64,7 +96,11 @@ module CloseYourIt
64
96
 
65
97
  # Broadcast opt-in di Rails.logger → CloseYourIt.log (config.capture_rails_logs, default OFF).
66
98
  # Spedisce solo i log dell'app ≥ logs_min_level. Richiede BroadcastLogger (Rails 7.1+).
67
- initializer "closeyourit.capture_rails_logs" do
99
+ # `after: :load_config_initializers`: config.capture_rails_logs è impostato in
100
+ # config/initializers/closeyourit.rb (CloseYourIt.init), che gira DOPO gli initializer dei
101
+ # railtie. Senza questo `after:` il check leggerebbe il default (false) e il broadcast non
102
+ # verrebbe mai agganciato → i log dell'app non arriverebbero a CloseYourIt.
103
+ initializer "closeyourit.capture_rails_logs", after: :load_config_initializers do
68
104
  config = CloseYourIt.configuration
69
105
  if config.capture_rails_logs && ::Rails.logger.respond_to?(:broadcast_to)
70
106
  ::Rails.logger.broadcast_to(CloseYourIt::Rails::LogBroadcast.new(config.logs_min_level))
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "../scrubber"
6
+
7
+ module CloseYourIt
8
+ module Rails
9
+ # Estrae i params del body della richiesta (`request.data`) al momento dell'EVENTO — mai
10
+ # eagerly a ogni richiesta (zero overhead sul percorso felice). Preferisce i params già
11
+ # parsati da Rails/Rack presenti in env; ripiega sulla rilettura di rack.input (con rewind)
12
+ # solo per JSON/form ≤ MAX_BODY_BYTES. Output sanitizzato (upload → "[FILE: …]", oggetti →
13
+ # "[OBJECT: …]", stringhe troncate) e scrubbato con la stessa denylist del resto del client.
14
+ module RequestBody
15
+ MAX_BODY_BYTES = 65_536
16
+ MAX_DEPTH = 8
17
+ MAX_STRING = 1024
18
+
19
+ FORM_TYPE = "application/x-www-form-urlencoded"
20
+ JSON_TYPE = "application/json"
21
+
22
+ class << self
23
+ def extract(env)
24
+ params = parsed_params(env) || raw_params(env)
25
+ return nil if params.nil? || params.empty?
26
+
27
+ Scrubber.new(CloseYourIt.configuration).filter_params(sanitize(params, 0))
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ private
33
+
34
+ # Params già parsati a monte (ActionDispatch o Rack): nessuna rilettura del body.
35
+ def parsed_params(env)
36
+ env["action_dispatch.request.request_parameters"] || env["rack.request.form_hash"]
37
+ end
38
+
39
+ def raw_params(env)
40
+ raw = read_body(env)
41
+ return nil if raw.nil? || raw.empty?
42
+
43
+ case env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
44
+ when JSON_TYPE
45
+ value = JSON.parse(raw)
46
+ value.is_a?(Hash) ? value : { "_json" => value }
47
+ when FORM_TYPE
48
+ # Fallback flat (stdlib): il percorso reale con nesting passa dai params già parsati.
49
+ URI.decode_www_form(raw).to_h
50
+ end
51
+ rescue JSON::ParserError, ArgumentError
52
+ nil
53
+ end
54
+
55
+ def read_body(env)
56
+ input = env["rack.input"]
57
+ return nil if input.nil?
58
+
59
+ length = env["CONTENT_LENGTH"].to_i
60
+ return nil if length <= 0 || length > MAX_BODY_BYTES
61
+
62
+ input.rewind if input.respond_to?(:rewind)
63
+ raw = input.read(MAX_BODY_BYTES)
64
+ input.rewind if input.respond_to?(:rewind)
65
+ raw
66
+ end
67
+
68
+ def sanitize(value, depth)
69
+ return "[TRUNCATED]" if depth > MAX_DEPTH
70
+
71
+ case value
72
+ when Hash then value.each_with_object({}) { |(key, val), acc| acc[key.to_s] = sanitize(val, depth + 1) }
73
+ when Array then value.map { |item| sanitize(item, depth + 1) }
74
+ when String then value.size > MAX_STRING ? "#{value[0, MAX_STRING]}…" : value
75
+ when Numeric, true, false, nil then value
76
+ else
77
+ if value.respond_to?(:original_filename)
78
+ "[FILE: #{value.original_filename}]"
79
+ else
80
+ "[OBJECT: #{value.class.name}]"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -20,7 +20,11 @@ module CloseYourIt
20
20
  if enabled?
21
21
  # trace_id sempre (correlazione log↔errori), anche con capture_request OFF.
22
22
  CloseYourIt::Scope.current.trace_id = trace_id_for(env)
23
- CloseYourIt::Scope.current.request = build_request(env) if CloseYourIt.configuration.capture_request
23
+ if CloseYourIt.configuration.capture_request
24
+ CloseYourIt::Scope.current.request = build_request(env)
25
+ # Riferimento all'env per l'estrazione LAZY del body (request.data) a evento costruito.
26
+ CloseYourIt::Scope.current.rack_env = env
27
+ end
24
28
  end
25
29
  @app.call(env)
26
30
  ensure
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "breadcrumb_buffer"
4
+ require_relative "performance/request_profile"
4
5
 
5
6
  module CloseYourIt
6
7
  # Contesto per-richiesta (o per-job) isolato per execution-context (Fiber storage):
@@ -34,7 +35,7 @@ module CloseYourIt
34
35
  end
35
36
  end
36
37
 
37
- attr_accessor :request, :trace_id
38
+ attr_accessor :request, :trace_id, :rack_env
38
39
  attr_reader :user, :tags, :extra, :contexts, :breadcrumbs
39
40
 
40
41
  def initialize
@@ -65,14 +66,22 @@ module CloseYourIt
65
66
  @breadcrumbs.add(breadcrumb)
66
67
  end
67
68
 
69
+ # Profilo di performance per-richiesta (query + HTTP esterne). Lazy: creato al primo accesso,
70
+ # azzerato da #clear a fine richiesta. Il verdetto lo calcola Subscribers::RequestPerformance.
71
+ def performance_profile
72
+ @performance_profile ||= Performance::RequestProfile.new
73
+ end
74
+
68
75
  def clear
69
76
  @user = {}
70
77
  @tags = {}
71
78
  @extra = {}
72
79
  @contexts = {}
73
80
  @request = nil
81
+ @rack_env = nil
74
82
  @trace_id = nil
75
83
  @breadcrumbs = BreadcrumbBuffer.new(CloseYourIt.configuration.max_breadcrumbs)
84
+ @performance_profile = nil
76
85
  end
77
86
 
78
87
  # Sottoinsieme non vuoto in forma evento Sentry (user/tags/extra/contexts/request),
@@ -85,7 +94,7 @@ module CloseYourIt
85
94
  "tags" => scrub(presence(@tags)),
86
95
  "extra" => scrub(presence(@extra)),
87
96
  "contexts" => scrub(presence(@contexts)),
88
- "request" => @request,
97
+ "request" => request_payload,
89
98
  "breadcrumbs" => breadcrumbs_payload
90
99
  }.reject { |_key, value| value.nil? }
91
100
  end
@@ -101,6 +110,24 @@ module CloseYourIt
101
110
  Scrubber.new(CloseYourIt.configuration).filter_params(hash)
102
111
  end
103
112
 
113
+ # Request context + body params (`request.data`) estratti LAZY qui — cioè solo quando un
114
+ # evento viene davvero costruito, mai sul percorso felice della richiesta.
115
+ def request_payload
116
+ return nil if @request.nil?
117
+
118
+ data = request_body_data
119
+ data ? @request.merge("data" => data) : @request
120
+ end
121
+
122
+ def request_body_data
123
+ return nil unless CloseYourIt.configuration.capture_request_body
124
+ return nil if @rack_env.nil?
125
+
126
+ Rails::RequestBody.extract(@rack_env)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
104
131
  # `user.id` sempre; email/ip_address/username solo con `send_pii` (il backend li strippa
105
132
  # comunque — difesa in profondità).
106
133
  def serialize_user
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scope"
4
+ require_relative "../performance/rollup"
5
+
6
+ module CloseYourIt
7
+ module Subscribers
8
+ # A fine richiesta (process_action.action_controller) trasforma il RequestProfile accumulato nello
9
+ # Scope in verdetti performance_issue e li spedisce (fire-and-forget). Lo Scope — e quindi il
10
+ # profilo — viene azzerato subito dopo da RequestContext#call (ensure). Logica pura: il wiring ad
11
+ # ActiveSupport::Notifications vive nel Railtie.
12
+ class RequestPerformance
13
+ def initialize(configuration = nil)
14
+ @configuration = configuration
15
+ end
16
+
17
+ def record(route:, duration_ms:)
18
+ config = @configuration || CloseYourIt.configuration
19
+ return unless config.detect_performance_issues
20
+
21
+ profile = CloseYourIt::Scope.current.performance_profile
22
+ return if profile.empty? && !slow_request?(config, duration_ms)
23
+
24
+ events = CloseYourIt::Performance::Rollup.call(
25
+ profile: profile, configuration: config, route: route,
26
+ request_duration_ms: duration_ms, trace_id: CloseYourIt::Scope.current.trace_id
27
+ )
28
+ events.each { |event| CloseYourIt.capture_event(event) }
29
+ end
30
+
31
+ private
32
+
33
+ def slow_request?(config, duration_ms)
34
+ duration_ms && config.slow_request_threshold_ms && duration_ms > config.slow_request_threshold_ms
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../events/slow_query_event"
4
4
  require_relative "../scrubber"
5
+ require_relative "../scope"
5
6
 
6
7
  module CloseYourIt
7
8
  module Subscribers
@@ -40,13 +41,33 @@ module CloseYourIt
40
41
  CloseYourIt.add_breadcrumb(
41
42
  category: "query",
42
43
  type: "query",
43
- message: Scrubber.new(config).obfuscate_sql(sql),
44
+ message: scrubber(config).obfuscate_sql(sql),
44
45
  data: { "name" => name, "duration_ms" => duration_ms.to_f.round(2), "cached" => cached }
45
46
  )
46
47
  end
47
48
 
49
+ # Spinge OGNI query non di sistema nel RequestProfile dello Scope (per la detection N+1 a fine
50
+ # richiesta). Solo se detect_performance_issues: fingerprint = SQL offuscato + call-site.
51
+ def profile(name:, sql:, duration_ms:, cached: false, source: nil)
52
+ config = @configuration || CloseYourIt.configuration
53
+ return unless config.detect_performance_issues
54
+ return if ignored_name?(name)
55
+
56
+ CloseYourIt::Scope.current.performance_profile.add_query(
57
+ fingerprint: scrubber(config).obfuscate_sql(sql),
58
+ source: source, duration_ms: duration_ms, cached: cached
59
+ )
60
+ rescue StandardError
61
+ # La telemetria non deve mai disturbare la query ospite.
62
+ nil
63
+ end
64
+
48
65
  private
49
66
 
67
+ def scrubber(config)
68
+ @scrubber ||= Scrubber.new(config)
69
+ end
70
+
50
71
  def ignored_name?(name)
51
72
  name.nil? || IGNORED_NAMES.include?(name)
52
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -16,15 +16,21 @@ require_relative "closeyourit/events/message_event"
16
16
  require_relative "closeyourit/events/slow_query_event"
17
17
  require_relative "closeyourit/events/slow_method_event"
18
18
  require_relative "closeyourit/events/log_event"
19
+ require_relative "closeyourit/events/performance_issue_event"
20
+ require_relative "closeyourit/performance/request_profile"
21
+ require_relative "closeyourit/performance/rollup"
19
22
  require_relative "closeyourit/log_device"
20
23
  require_relative "closeyourit/log_buffer"
21
24
  require_relative "closeyourit/subscribers/slow_query"
25
+ require_relative "closeyourit/subscribers/request_performance"
22
26
  require_relative "closeyourit/instrumenter"
23
27
  require_relative "closeyourit/monitor"
24
28
  require_relative "closeyourit/client"
25
29
  require_relative "closeyourit/rails/capture_exceptions"
26
30
  require_relative "closeyourit/rails/request_context"
31
+ require_relative "closeyourit/rails/request_body"
27
32
  require_relative "closeyourit/rails/log_broadcast"
33
+ require_relative "closeyourit/rails/net_http_patch"
28
34
  require_relative "closeyourit/rails/active_job_extension"
29
35
  require_relative "closeyourit/rails/error_subscriber"
30
36
  require_relative "closeyourit/sidekiq/error_handler"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: closeyourit-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessio Bussolari
@@ -43,23 +43,30 @@ files:
43
43
  - lib/closeyourit/events/error_event.rb
44
44
  - lib/closeyourit/events/log_event.rb
45
45
  - lib/closeyourit/events/message_event.rb
46
+ - lib/closeyourit/events/performance_issue_event.rb
46
47
  - lib/closeyourit/events/slow_method_event.rb
47
48
  - lib/closeyourit/events/slow_query_event.rb
48
49
  - lib/closeyourit/instrumenter.rb
50
+ - lib/closeyourit/line_cache.rb
49
51
  - lib/closeyourit/log_buffer.rb
50
52
  - lib/closeyourit/log_device.rb
51
53
  - lib/closeyourit/monitor.rb
54
+ - lib/closeyourit/performance/request_profile.rb
55
+ - lib/closeyourit/performance/rollup.rb
52
56
  - lib/closeyourit/rails/active_job_extension.rb
53
57
  - lib/closeyourit/rails/capture_exceptions.rb
54
58
  - lib/closeyourit/rails/error_subscriber.rb
55
59
  - lib/closeyourit/rails/log_broadcast.rb
60
+ - lib/closeyourit/rails/net_http_patch.rb
56
61
  - lib/closeyourit/rails/query_source.rb
57
62
  - lib/closeyourit/rails/railtie.rb
63
+ - lib/closeyourit/rails/request_body.rb
58
64
  - lib/closeyourit/rails/request_context.rb
59
65
  - lib/closeyourit/scope.rb
60
66
  - lib/closeyourit/scrubber.rb
61
67
  - lib/closeyourit/sidekiq/error_handler.rb
62
68
  - lib/closeyourit/stats.rb
69
+ - lib/closeyourit/subscribers/request_performance.rb
63
70
  - lib/closeyourit/subscribers/slow_query.rb
64
71
  - lib/closeyourit/transport.rb
65
72
  - lib/closeyourit/version.rb