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 +4 -4
- data/README.md +3 -3
- data/lib/closeyourit/configuration.rb +25 -2
- data/lib/closeyourit/events/error_event.rb +17 -2
- data/lib/closeyourit/events/performance_issue_event.rb +42 -0
- data/lib/closeyourit/events/slow_query_event.rb +2 -0
- data/lib/closeyourit/line_cache.rb +41 -0
- data/lib/closeyourit/performance/request_profile.rb +51 -0
- data/lib/closeyourit/performance/rollup.rb +76 -0
- data/lib/closeyourit/rails/net_http_patch.rb +60 -0
- data/lib/closeyourit/rails/railtie.rb +37 -1
- data/lib/closeyourit/rails/request_body.rb +87 -0
- data/lib/closeyourit/rails/request_context.rb +5 -1
- data/lib/closeyourit/scope.rb +29 -2
- data/lib/closeyourit/subscribers/request_performance.rb +38 -0
- data/lib/closeyourit/subscribers/slow_query.rb +22 -1
- data/lib/closeyourit/version.rb +1 -1
- data/lib/closeyourit-ruby.rb +6 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8d2868773842804dc2c90f163ea4766ea748c59edf8adaa956f257827391e8c
|
|
4
|
+
data.tar.gz: 7fe183c1a6c64226f341b09c7da4cd37bf58bb75409b9afc20c223efc55ffe1f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/closeyourit/scope.rb
CHANGED
|
@@ -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" =>
|
|
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:
|
|
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
|
data/lib/closeyourit/version.rb
CHANGED
data/lib/closeyourit-ruby.rb
CHANGED
|
@@ -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.
|
|
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
|