closeyourit-ruby 0.5.0 → 0.6.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: 6dc227e626e4b47b931a80698aa8b2567bacb033dad2e5e42d6c8497890debe2
4
- data.tar.gz: d012c3b1272b238e796ff7cdb1cf687276932a0c5196f14fbd0d5393edf9d938
3
+ metadata.gz: c80dd7130394211bc1c0ebbf7a13bbe9906951b667734380bdf38aac53f194ae
4
+ data.tar.gz: 9adde552df65cee4cd32d47161bdd84fbe5a69c06698b9fc5c9cfd5a1a2a1088
5
5
  SHA512:
6
- metadata.gz: c4cd8b4aaa3cb4ff246d2607d3f65ec71c696c37b8ccb9525979c99175bcf5f1c35f051574d034b8cb7198fb62362c2683e74e076989cf3e2ea3ae34408385a7
7
- data.tar.gz: 0b54035901e710005c1cc45339a30f0df0277e0d0708c9faff417a536eba0000b77b2a2bbb9c7b608b8528b4ac04b65f7f16b31dbd8320ec1e2d37f9465db2a4
6
+ metadata.gz: 1f7cc16b574bfc124e9f9f49037a7045dc30a7125da890f78ae6c7c36712a418da61dbd2170a12a8d34be35ea5fc8fb96568dc58e1e573677fe67afb902c071e
7
+ data.tar.gz: aa9871930fa03617485c993e2f84db54aeb23ecd092c8a2a15a463e9dcda75c710d3cadef95f14081bbe3844cca50f4215ef0c5b40cb149e205ff1d6e701a575
data/README.md CHANGED
@@ -72,6 +72,7 @@ end
72
72
  | `before_send` | `nil` | `->(payload) { ... }` — scrub finale; ritorna payload o `nil` per scartare |
73
73
  | `sample_rate` | `1.0` | Frazione di errori/messaggi inviata (`1.0` tutto, `0.0` niente) |
74
74
  | `async_threads` | `cpu/2` | Thread di invio; `0` = sincrono (test) |
75
+ | `trap_signals` | `false` | Intercetta `SIGTERM` per garantire il flush di fine-vita (deploy/Kamal); opt-in perché sovrascrive un eventuale handler `TERM` dell'app ospite (vedi [Flush di fine-vita](#flush-di-fine-vita)) |
75
76
  | `slow_query_threshold_ms` | `100` | Soglia query lente |
76
77
  | `slow_method_threshold_ms` | `200` | Soglia metodi lenti |
77
78
  | `capture_request` | `true` | Cattura il contesto HTTP della richiesta (method/url/header allowlist) |
@@ -83,7 +84,21 @@ end
83
84
  | `send_pii` | `false` | Master switch PII |
84
85
  | `obfuscate_sql` | `true` | Maschera i literal nello SQL |
85
86
  | `filter_parameters` | `[]` | Chiavi extra da redarre (mergiate con quelle di Rails) |
86
- | `scrub_message_patterns` | `[]` | Regexp da redarre dai messaggi d'eccezione |
87
+ | `scrub_message_patterns` | `[]` | Regexp da redarre da messaggi d'eccezione **e da `message` dei log** |
88
+ | `logs_enabled` | `true` | Master switch dello stream di log strutturato |
89
+ | `logs_min_level` | `:info` | Soglia minima: i log sotto (es. `debug`) non vengono inviati |
90
+ | `logs_sample_rate` | `1.0` | Frazione di log inviata (`1.0` tutto, `0.0` niente) |
91
+ | `logs_batch_size` | `50` | Log accumulati nel buffer prima di un flush a batch verso `/logs` |
92
+ | `logs_flush_interval` | `5` | Secondi tra i flush periodici del buffer log |
93
+ | `capture_rails_logs` | `false` | Inoltra anche `Rails.logger` allo stream — attenzione al volume (soglia `capture_rails_logs_min_level`) |
94
+ | `capture_rails_logs_min_level` | `:warn` | Soglia **dedicata** del broadcast `Rails.logger` (distinta da `logs_min_level`): default conservativo per non inondare lo stream col rumore `info` del framework |
95
+ | `detect_performance_issues` | `false` | Master switch dei **verdetti** performance (N+1, query count, request/HTTP esterne lente). Opt-in: profila ogni query → overhead (vedi [Performance issues](#performance-issues-opt-in)) |
96
+ | `n_plus_one_threshold` | `10` | Ripetizioni dello stesso `[fingerprint, call-site]` in una richiesta oltre cui = `n_plus_one` |
97
+ | `query_count_threshold` | `100` | Query totali in una richiesta oltre cui = `high_query_count` |
98
+ | `query_time_threshold_ms` | `500` | Tempo DB totale per richiesta (ms) oltre cui = `high_query_count` |
99
+ | `slow_request_threshold_ms` | `1000` | Durata totale della richiesta (ms) oltre cui = `slow_request` |
100
+ | `slow_external_threshold_ms` | `1000` | Durata di una singola HTTP esterna (ms) oltre cui = `slow_external_http` |
101
+ | `capture_external_http` | `true` | Strumenta `Net::HTTP` per rilevare le HTTP esterne (effettivo solo con `detect_performance_issues`) |
87
102
 
88
103
  ## Cosa cattura
89
104
 
@@ -116,6 +131,55 @@ Query string e IP solo con `send_pii`. Lo scope è resettato a fine richiesta (n
116
131
  `mechanism.handled = true` e il `level` mappato dalla severity. La dedup garantisce un solo invio anche
117
132
  se la stessa eccezione passa da più punti (Rack + job + reporter).
118
133
 
134
+ ### Log strutturati (manuale/automatico) → stream log
135
+
136
+ Stream di log strutturati (`level`/`message`/`attributes`/`logger`) separato dagli errori, correlato via
137
+ `trace_id` (stesso della richiesta). Bufferizzato e spedito a batch a `/logs`. `message` **e**
138
+ `attributes` passano dallo scrubber (denylist chiavi + `scrub_message_patterns`) come gli errori.
139
+
140
+ ```ruby
141
+ # API esplicita — il keyword `logger:` è la sorgente del log; ogni altra keyword è un attributo
142
+ CloseYourIt.log(:info, "ordine creato", order_id: order.id, amount: order.amount)
143
+ CloseYourIt.log(:warning, "retry pagamento", attempt: 2)
144
+
145
+ # Interfaccia ::Logger-like (level via metodo)
146
+ CloseYourIt.logger.warn("cache miss", key: cache_key)
147
+ CloseYourIt.logger.error("job fallito", job: self.class.name)
148
+ ```
149
+
150
+ La **sorgente** del log (campo `logger`) si imposta con `.named` — child logger immutabile, stesso
151
+ idioma di dart/js. Il logger radice resta invariato:
152
+
153
+ ```ruby
154
+ payments = CloseYourIt.logger.named("payments") # child con sorgente "payments"
155
+ payments.warn("retry", attempt: 3) # logger="payments", attributes={attempt: 3}
156
+ ```
157
+
158
+ Sull'interfaccia `.logger` una chiave `logger:` è **sempre un attributo dati** (non viene hijackata
159
+ come sorgente): `CloseYourIt.logger.warn("disco pieno", logger: "/dev/sda1")` mette `logger` negli
160
+ `attributes`. Sorgente (`.named`) e attributo `logger` possono coesistere senza collidere.
161
+
162
+ Ogni log sotto `logs_min_level` (default `:info`) è **scartato** prima di essere costruito o spedito —
163
+ `CloseYourIt.log` e `CloseYourIt.logger.*` usano la mappa di livelli (`debug` < `info` < `warning` <
164
+ `error` < `fatal`), identica agli SDK dart/js. Un `CloseYourIt.logger.debug(...)` in un loop caldo con
165
+ la soglia di default non produce alcun invio.
166
+
167
+ Con Rails, `capture_rails_logs = true` inoltra anche `Rails.logger` allo stream, ma con una soglia
168
+ **dedicata** `capture_rails_logs_min_level` (default `:warn`, distinta da `logs_min_level`): in
169
+ produzione ad alto traffico Rails logga a `:info` ogni richiesta (`Started GET`, `Rendered`, ...) e un
170
+ default a `:info` inonderebbe lo stream col rumore del framework — la soglia conservativa lo evita.
171
+ Abbassala esplicitamente (es. `config.capture_rails_logs_min_level = :info`) solo se vuoi anche gli
172
+ `info` del framework. I log dei background job ereditano il `trace_id = job_id`, così log ed errore
173
+ dello stesso job si correlano.
174
+
175
+ #### Flush di fine-vita
176
+
177
+ Il buffer log viene svuotato automaticamente allo **shutdown del processo**: `CloseYourIt.init` registra
178
+ un `at_exit` che drena anche il worker asincrono (attende gli invii in volo, non solo il flush del
179
+ buffer), così i log sotto-batch di rake/CLI non vanno persi all'uscita. `SIGTERM` (deploy/Kamal)
180
+ termina il processo **senza** eseguire gli `at_exit`: imposta `trap_signals = true` per intercettarlo e
181
+ convertirlo in un exit pulito, oppure chiama `CloseYourIt.shutdown` esplicitamente prima di terminare.
182
+
119
183
  ### Contesto, breadcrumbs e messaggi (manuale)
120
184
 
121
185
  ```ruby
@@ -156,6 +220,43 @@ end
156
220
  Viene inviato un `slow_method` solo se la durata supera `slow_method_threshold_ms`. **Gli argomenti
157
221
  del metodo non vengono mai inviati** (solo label, durata, file:riga).
158
222
 
223
+ ## Performance issues (opt-in)
224
+
225
+ `slow_query` e `slow_method` sono metriche **puntuali** (una query/un metodo lenti). Oltre a queste,
226
+ con **`detect_performance_issues = true`** la gemma profila **ogni query della richiesta**
227
+ (fingerprint + call-site) e, a fine richiesta, emette 0..N **verdetti** `performance_issue` sulla
228
+ pipeline metriche (`/api/v1/projects/:id/metrics`). Un verdetto non è una singola query lenta: è un
229
+ **pattern dell'intera richiesta**, aggregato e correlato a log/errori via `trace_id`. I quattro
230
+ `subtype`:
231
+
232
+ - **`n_plus_one`** — lo stesso `[fingerprint SQL, call-site]` eseguito più di `n_plus_one_threshold`
233
+ volte (default 10) nella stessa richiesta: il classico N+1. Un verdetto per gruppo, con lo SQL già
234
+ offuscato e il `source` (`file:riga`) che lo ha originato.
235
+ - **`high_query_count`** — troppe query in una richiesta: numero totale oltre `query_count_threshold`
236
+ (default 100) **oppure** tempo DB totale oltre `query_time_threshold_ms` (default 500 ms).
237
+ - **`slow_request`** — durata totale della richiesta oltre `slow_request_threshold_ms` (default
238
+ 1000 ms), con `route`, `query_count` e tempo DB come contesto.
239
+ - **`slow_external_http`** — una singola chiamata HTTP esterna (`Net::HTTP`) oltre
240
+ `slow_external_threshold_ms` (default 1000 ms). Richiede `capture_external_http` (default `true`, che
241
+ strumenta `Net::HTTP` **solo** quando la detection è attiva); host e path templatizzato, mai la query string.
242
+
243
+ ```ruby
244
+ CloseYourIt.init do |c|
245
+ # …endpoint_url / token / project_id…
246
+ c.detect_performance_issues = true
247
+ end
248
+ ```
249
+
250
+ Con Rails il wiring è **automatico** (il Railtie si iscrive a `process_action.action_controller`):
251
+ attivato il master switch, non serve altro codice. Le soglie sopra sono conservative (poco rumore) e
252
+ tutte ritoccabili.
253
+
254
+ > **Overhead — perché è opt-in (default OFF).** Per aggregare i verdetti la detection profila **ogni**
255
+ > query della richiesta (fingerprint + call-site): un costo non trascurabile, da attivare
256
+ > consapevolmente per-app. `slow_query`/`slow_method` restano attive e **indipendenti** da questo
257
+ > switch. (Pendant server-side dei verdetti `performance_issue` di closeyourit-js: qui i `subtype`
258
+ > sono quelli del backend Rails — N+1, query count, request/HTTP esterne lente.)
259
+
159
260
  ## Privacy & PII
160
261
 
161
262
  Privacy-by-default (`send_pii = false`). In sintesi:
@@ -27,6 +27,13 @@ module CloseYourIt
27
27
  accepted = @worker.perform { @transport.send_event(payload, path: path) }
28
28
  CloseYourIt.stats.increment(:enqueued) if accepted
29
29
  payload
30
+ rescue StandardError => e
31
+ # La telemetria non deve MAI propagare nel path dell'app ospite: capture_event è invocato dal
32
+ # subscriber sql.active_record, che gira nel thread della query. to_h (scrubber su bind
33
+ # malformato) e before_send sono valutati qui in modo sincrono → se sollevano, assorbiamo,
34
+ # logghiamo e scartiamo l'evento invece di disturbare la query ospite. Vedi CYRB-2.
35
+ CloseYourIt.internal_logger.error("CloseYourIt client: #{e.class}: #{e.message}")
36
+ nil
30
37
  end
31
38
 
32
39
  # Invia un batch di log come ARRAY a /logs (l'endpoint accetta singolo o array). before_send è
@@ -43,7 +50,8 @@ module CloseYourIt
43
50
 
44
51
  path = events.first.ingest_path(@configuration.project_id)
45
52
  payloads.each_slice(LOGS_MAX_BATCH) do |chunk|
46
- @worker.perform { @transport.send_event(chunk, path: path) }
53
+ accepted = @worker.perform { @transport.send_event(chunk, path: path) }
54
+ CloseYourIt.stats.increment(:enqueued) if accepted
47
55
  end
48
56
  payloads
49
57
  end
@@ -25,11 +25,11 @@ module CloseYourIt
25
25
  :breadcrumbs_enabled, :max_breadcrumbs, :sample_rate,
26
26
  :capture_handled_errors, :report_active_job_errors,
27
27
  :logs_enabled, :logs_sample_rate, :logs_batch_size, :logs_flush_interval,
28
- :capture_rails_logs, :logs_min_level,
28
+ :capture_rails_logs, :logs_min_level, :capture_rails_logs_min_level,
29
29
  :detect_performance_issues, :n_plus_one_threshold, :query_count_threshold,
30
30
  :query_time_threshold_ms, :slow_request_threshold_ms, :slow_external_threshold_ms,
31
- :capture_external_http
32
- attr_writer :release
31
+ :capture_external_http, :trap_signals
32
+ attr_writer :release, :project_root
33
33
  attr_reader :excluded_exceptions, :filter_parameters, :scrub_message_patterns
34
34
 
35
35
  def initialize
@@ -45,6 +45,11 @@ module CloseYourIt
45
45
  @async_threads = default_threads
46
46
  @background_worker_max_queue = 30
47
47
 
48
+ # Intercetta SIGTERM per garantire il flush di fine-vita (deploy/Kamal): SIGTERM di default
49
+ # termina il processo SENZA eseguire gli at_exit. OPT-IN perché sovrascrive un eventuale handler
50
+ # TERM dell'app ospite. Vedi CloseYourIt.shutdown / register_shutdown_flush (CYRB-5).
51
+ @trap_signals = false
52
+
48
53
  @slow_query_threshold_ms = 100
49
54
  @slow_method_threshold_ms = 200
50
55
 
@@ -87,6 +92,12 @@ module CloseYourIt
87
92
  # Broadcast opt-in di Rails.logger → CloseYourIt.log (default OFF; spedisce solo ≥ soglia).
88
93
  @capture_rails_logs = false
89
94
  @logs_min_level = :info
95
+ # Soglia DEDICATA del broadcast Rails.logger, distinta da logs_min_level (che governa
96
+ # CloseYourIt.log/.logger, dove è il dev a scegliere cosa loggare). Default :warn — conservativo:
97
+ # senza, in produzione ad alto traffico OGNI riga info del framework (Started GET, Rendered, ...)
98
+ # inonderebbe lo stream con decine di migliaia di log-entry/min (CYRB-7). Chi vuole anche gli info
99
+ # del framework la abbassa esplicitamente (es. :info).
100
+ @capture_rails_logs_min_level = :warn
90
101
 
91
102
  # Performance issue detection (verdetti aggregati: N+1, slow request, HTTP esterne lente).
92
103
  # OPT-IN, default OFF: profila OGNI query della richiesta → overhead non trascurabile, va attivato
@@ -99,6 +110,10 @@ module CloseYourIt
99
110
  @slow_external_threshold_ms = 1000 # singola chiamata HTTP esterna
100
111
  @capture_external_http = true # strumenta Net::HTTP (solo se detect_performance_issues)
101
112
 
113
+ # Radice del progetto: base per il filename relativo dei frame (culprit cross-SDK). Lazy:
114
+ # auto-rilevata da Rails.root o Dir.pwd al primo accesso se non impostata esplicitamente.
115
+ @project_root = nil
116
+
102
117
  @filter_parameters = []
103
118
  @scrub_message_patterns = []
104
119
  end
@@ -145,6 +160,23 @@ module CloseYourIt
145
160
  @release = detect_release
146
161
  end
147
162
 
163
+ # Radice del progetto effettiva: quella impostata, altrimenti auto-rilevata (Rails.root o Dir.pwd).
164
+ # Usata per rendere `frame.filename` relativo (culprit confrontabile cross-SDK — CYRB-4).
165
+ def project_root
166
+ return @project_root unless @project_root.nil?
167
+
168
+ @project_root = detect_project_root
169
+ end
170
+
171
+ # Rails.root quando l'app gira sotto Rails, altrimenti la working directory. Mai solleva.
172
+ def detect_project_root
173
+ return ::Rails.root.to_s if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
174
+
175
+ Dir.pwd
176
+ rescue StandardError
177
+ Dir.pwd
178
+ end
179
+
148
180
  # Auto-rilevamento release dalle env di deploy/CI o dal git short SHA. Mai solleva.
149
181
  def detect_release
150
182
  ENV["KAMAL_VERSION"] ||
@@ -75,13 +75,15 @@ module CloseYourIt
75
75
 
76
76
  # Sentry-style: frame più recente per ultimo; chiavi filename/function/lineno/in_app/abs_path
77
77
  # + snippet di sorgente (pre_context/context_line/post_context) quando il file è leggibile.
78
+ # `filename` è relativo/basename (culprit confrontabile cross-SDK — CYRB-4); `abs_path` è il
79
+ # path assoluto per il dettaglio e per leggere il sorgente delle context lines.
78
80
  def frames(locations)
79
81
  return [] if locations.nil?
80
82
 
81
83
  context_lines = @configuration.context_lines.to_i
82
84
  locations.reverse.map do |loc|
83
85
  frame = {
84
- "filename" => loc.path,
86
+ "filename" => relative_filename(loc.path),
85
87
  "abs_path" => loc.path,
86
88
  "function" => loc.label,
87
89
  "lineno" => loc.lineno,
@@ -92,6 +94,21 @@ module CloseYourIt
92
94
  end
93
95
  end
94
96
 
97
+ # Path da mostrare (Sentry `filename`): relativo alla project_root se il file è dentro l'app
98
+ # (es. "app/models/user.rb"), altrimenti il basename (gem/stdlib/path esterni → "base.rb").
99
+ # Allinea Ruby agli altri SDK (Dart/JS mandano basename/relativo), così il culprit
100
+ # "filename in function" è confrontabile a colpo d'occhio su progetti poliglotti. Vedi CYRB-4.
101
+ def relative_filename(path)
102
+ return path if path.nil?
103
+
104
+ root = @configuration.project_root.to_s
105
+ prefix = root.empty? ? nil : File.join(root, "") # root con separatore finale, portabile
106
+ return File.basename(path) unless prefix && path.start_with?(prefix)
107
+
108
+ relative = path[prefix.length..]
109
+ relative.nil? || relative.empty? ? File.basename(path) : relative
110
+ end
111
+
95
112
  def add_context!(frame, path, lineno, count)
96
113
  lines = LineCache.lines(path)
97
114
  return if lines.nil? || lineno.nil? || lineno < 1 || lineno > lines.size
@@ -1,23 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "../scrubber"
4
5
 
5
6
  module CloseYourIt
6
7
  # Voce di log strutturata spedita all'ingest /logs (NON formato Sentry: i log sono uno stream con
7
- # message/level/attributes/logger). Gli `attributes` passano dallo Scrubber (denylist). `trace_id`
8
- # è preso dallo Scope corrente (popolato per richiesta) correlazione log↔errori della stessa request.
8
+ # message/level/attributes/logger). `message` E `attributes` passano dallo Scrubber (denylist +
9
+ # pattern) come ErrorEvent un log può contenere PII/segreti quanto un errore. `trace_id` è
10
+ # congelato alla COSTRUZIONE (thread della richiesta) → correlazione log↔errori della stessa request
11
+ # anche quando il flush avviene su un thread timer diverso.
9
12
  class LogEvent < Event
10
13
  # Livelli canonici del backend (enum) + alias dei nomi stile ::Logger. Normalizzati QUI (fonte
11
14
  # unica) così ogni costruzione — via CloseYourIt.log, .logger o diretta — produce un livello valido.
15
+ # L'indice in LEVELS è anche la severità numerica del livello (debug=0 … fatal=4): la STESSA mappa
16
+ # di dart/js, così il gating per soglia (`logs_min_level`) filtra identico cross-SDK (CYRB-6).
12
17
  LEVELS = %w[debug info warning error fatal].freeze
13
18
  LEVEL_ALIASES = { "warn" => "warning", "err" => "error", "unknown" => "fatal" }.freeze
14
19
 
20
+ # Normalizza qualsiasi livello (simbolo/stringa, maiuscole, alias) al nome canonico del backend.
21
+ # Ignoto → "info". Metodo di classe: fonte unica riusata da `CloseYourIt.log` per il gating.
22
+ def self.normalize_level(level)
23
+ value = level.to_s.downcase
24
+ value = LEVEL_ALIASES.fetch(value, value)
25
+ LEVELS.include?(value) ? value : "info"
26
+ end
27
+
28
+ # Severità numerica del livello (indice in LEVELS), identica alla mappa cross-SDK. Usata per il
29
+ # confronto con `logs_min_level` prima di costruire/spedire il log.
30
+ def self.severity(level)
31
+ LEVELS.index(normalize_level(level))
32
+ end
33
+
15
34
  def initialize(message, level:, attributes:, configuration:, logger: nil)
16
35
  super(configuration)
17
36
  @message = message
18
- @level = normalize_level(level)
37
+ @level = self.class.normalize_level(level)
19
38
  @attributes = attributes || {}
20
39
  @logger = logger
40
+ @scrubber = Scrubber.new(configuration)
41
+ # trace_id catturato QUI, sul thread della richiesta: il LogEvent viene bufferizzato e flushato
42
+ # su un thread timer diverso, dove lo Scope corrente è di un'ALTRA richiesta (o vuoto) → leggerlo
43
+ # lazy in to_h romperebbe la correlazione log↔errore. Lo congeliamo alla costruzione.
44
+ @trace_id = CloseYourIt::Scope.current.trace_id
21
45
  end
22
46
 
23
47
  def to_h
@@ -25,10 +49,10 @@ module CloseYourIt
25
49
  "event_id" => SecureRandom.uuid.delete("-"),
26
50
  "timestamp" => @occurred_at,
27
51
  "level" => @level,
28
- "message" => @message.to_s,
52
+ "message" => @scrubber.scrub_message(@message.to_s),
29
53
  "attributes" => scrubbed_attributes,
30
54
  "logger" => @logger,
31
- "trace_id" => CloseYourIt::Scope.current.trace_id,
55
+ "trace_id" => @trace_id,
32
56
  "environment" => environment,
33
57
  "release" => @configuration.release,
34
58
  "sdk" => sdk
@@ -41,16 +65,10 @@ module CloseYourIt
41
65
 
42
66
  private
43
67
 
44
- def normalize_level(level)
45
- value = level.to_s.downcase
46
- value = LEVEL_ALIASES.fetch(value, value)
47
- LEVELS.include?(value) ? value : "info"
48
- end
49
-
50
68
  def scrubbed_attributes
51
69
  return {} if @attributes.nil? || @attributes.empty?
52
70
 
53
- Scrubber.new(@configuration).filter_params(deep_stringify_keys(@attributes))
71
+ @scrubber.filter_params(deep_stringify_keys(@attributes))
54
72
  end
55
73
 
56
74
  # Chiavi sempre stringa (anche annidate): coerenza col payload JSON e con la denylist dello Scrubber.
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "../scrubber"
4
5
 
5
6
  module CloseYourIt
6
7
  # Messaggio diagnostico esplicito (`CloseYourIt.capture_message`) nel formato evento Sentry
7
- # (`message.formatted` + level). Fonde lo Scope corrente come ErrorEvent.
8
+ # (`message.formatted` + level). Fonde lo Scope corrente come ErrorEvent. Il messaggio passa dallo
9
+ # Scrubber (pattern) come `exception.message` di ErrorEvent — parità PII cross-evento.
8
10
  class MessageEvent < Event
9
11
  def initialize(message, level:, configuration:)
10
12
  super(configuration)
11
13
  @message = message
12
14
  @level = level
15
+ @scrubber = Scrubber.new(configuration)
13
16
  end
14
17
 
15
18
  def to_h
@@ -18,10 +21,12 @@ module CloseYourIt
18
21
  "timestamp" => @occurred_at,
19
22
  "platform" => "ruby",
20
23
  "level" => @level,
24
+ # Correlazione log↔errori: stesso trace_id dei log della medesima richiesta (parità con ErrorEvent).
25
+ "trace_id" => CloseYourIt::Scope.current.trace_id,
21
26
  "environment" => environment,
22
27
  "release" => @configuration.release,
23
28
  "server_name" => server_name,
24
- "message" => { "formatted" => @message },
29
+ "message" => { "formatted" => @scrubber.scrub_message(@message.to_s) },
25
30
  "contexts" => { "runtime" => { "name" => "ruby", "version" => RUBY_VERSION } },
26
31
  "sdk" => sdk
27
32
  )
@@ -32,6 +32,11 @@ module CloseYourIt
32
32
  return if batch.empty?
33
33
 
34
34
  @client.flush_logs(batch)
35
+ rescue StandardError => e
36
+ # Il flush gira sia sul thread della richiesta (batch pieno) sia sul thread timer: un to_h /
37
+ # before_send che solleva su UN evento non deve propagare nell'app né uccidere il TimerTask
38
+ # (che altrimenti smette di flushare → log accumulati e persi). Ingoiato e loggato.
39
+ CloseYourIt.internal_logger.error("CloseYourIt log buffer: #{e.class}: #{e.message}")
35
40
  end
36
41
 
37
42
  def shutdown
@@ -2,13 +2,32 @@
2
2
 
3
3
  module CloseYourIt
4
4
  # Oggetto Logger-compatibile esposto come `CloseYourIt.logger`: ogni chiamata inoltra a
5
- # `CloseYourIt.log` (→ ingest /logs). Usabile come logger esplicito dell'app, anche con attributes:
5
+ # `CloseYourIt.emit_log` (→ ingest /logs). Usabile come logger esplicito dell'app, anche con attributes:
6
6
  # CloseYourIt.logger.warn("disco quasi pieno", disk: "sda1")
7
7
  # `warn` mappa sul livello `warning` (enum backend); supporta block e `::Logger#add` per drop-in.
8
+ #
9
+ # La sorgente del log (campo `logger` del payload) si imposta SOLO via `.named` (child logger, parità
10
+ # dart/js) o via costruttore — MAI da una keyword dei metodi: così una chiave `logger` passata a un
11
+ # metodo resta un attributo dati e non viene hijackata come sorgente (CYRB-8).
12
+ # payments = CloseYourIt.logger.named("payments") # child con sorgente "payments"
13
+ # payments.warn("retry", attempt: 3) # logger=payments, attributes={attempt:3}
14
+ # CloseYourIt.logger.warn("disco pieno", logger: "/dev/sda1") # attributes={logger:"/dev/sda1"}
8
15
  class LogDevice
9
16
  # Severità numeriche ::Logger → livelli CloseYourIt (UNKNOWN→fatal).
10
17
  SEVERITY_LEVELS = { 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal", 5 => "fatal" }.freeze
11
18
 
19
+ # `source` = nome della sorgente del log (campo `logger` del payload). Preferire `.named` in app.
20
+ def initialize(source = nil)
21
+ @source = normalize_source(source)
22
+ end
23
+
24
+ # Child logger con sorgente esplicita (parità dart/js). Ritorna un NUOVO LogDevice: il logger su
25
+ # cui è chiamato resta invariato (immutabile), come i child logger cross-SDK.
26
+ # CloseYourIt.logger.named("payments").info("retry", attempt: 3)
27
+ def named(source)
28
+ self.class.new(source)
29
+ end
30
+
12
31
  def debug(message = nil, **attributes, &block) = write("debug", message, attributes, &block)
13
32
  def info(message = nil, **attributes, &block) = write("info", message, attributes, &block)
14
33
  def warn(message = nil, **attributes, &block) = write("warning", message, attributes, &block)
@@ -33,7 +52,17 @@ module CloseYourIt
33
52
  return unless CloseYourIt.logs_active?
34
53
 
35
54
  message = block.call if block
36
- CloseYourIt.log(level, message, **attributes)
55
+ # Sorgente e attributes separati: gli `attributes` (inclusa un'eventuale chiave `logger`) restano
56
+ # dati; la sorgente è SOLO `@source` (via `.named`/costruttore) → nessuna collisione (CYRB-8).
57
+ CloseYourIt.emit_log(level, message, source: @source, attributes: attributes)
58
+ end
59
+
60
+ # Sorgente vuota/blank → nessuna sorgente (nil); altrimenti stringa (il campo `logger` è testuale).
61
+ def normalize_source(source)
62
+ return nil if source.nil?
63
+
64
+ value = source.to_s.strip
65
+ value.empty? ? nil : value
37
66
  end
38
67
  end
39
68
  end
@@ -36,6 +36,10 @@ module CloseYourIt
36
36
  context["job_id"] = job.job_id if job.respond_to?(:job_id)
37
37
  context["executions"] = job.executions if job.respond_to?(:executions)
38
38
  CloseYourIt.set_context("active_job", context) unless context.empty?
39
+
40
+ # Un trace_id stabile per esecuzione (job_id è unico per run ActiveJob): log ed errore dello
41
+ # stesso job condividono il trace_id → correlazione log↔errore anche fuori dal ciclo richiesta.
42
+ CloseYourIt::Scope.current.trace_id = job.job_id if job.respond_to?(:job_id)
39
43
  end
40
44
  end
41
45
  end
@@ -5,15 +5,17 @@ require "logger"
5
5
  module CloseYourIt
6
6
  module Rails
7
7
  # Sink Logger-compatibile agganciato a Rails.logger (broadcast opt-in, `config.capture_rails_logs`):
8
- # ogni log dell'app ≥ soglia (`logs_min_level`) viene re-inoltrato a CloseYourIt.log → ingest /logs.
9
- # Sotto soglia: no-op (nessun invio). Non scrive su alcun device (inoltra soltanto).
8
+ # ogni log dell'app ≥ soglia (`capture_rails_logs_min_level`, default :warn) viene re-inoltrato a
9
+ # CloseYourIt.log → ingest /logs. Sotto soglia: no-op (nessun invio). Il default è conservativo
10
+ # (:warn) per non inondare lo stream col rumore info del framework (CYRB-7). Non scrive su alcun
11
+ # device (inoltra soltanto).
10
12
  class LogBroadcast < ::Logger
11
13
  SEVERITY_LEVELS = { 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal", 5 => "fatal" }.freeze
12
14
  LEVEL_BY_SYMBOL = { debug: 0, info: 1, warn: 2, warning: 2, error: 3, fatal: 4 }.freeze
13
15
 
14
- def initialize(min_level = :info)
16
+ def initialize(min_level = :warn)
15
17
  super(nil) # nessun device: inoltra soltanto
16
- self.level = LEVEL_BY_SYMBOL.fetch(min_level.to_sym, 1)
18
+ self.level = LEVEL_BY_SYMBOL.fetch(min_level.to_sym, 2)
17
19
  end
18
20
 
19
21
  # Sovrascrive il punto unico di ::Logger: filtra per soglia e re-inoltra a CloseYourIt.log.
@@ -95,7 +95,10 @@ module CloseYourIt
95
95
  end
96
96
 
97
97
  # Broadcast opt-in di Rails.logger → CloseYourIt.log (config.capture_rails_logs, default OFF).
98
- # Spedisce solo i log dell'app ≥ logs_min_level. Richiede BroadcastLogger (Rails 7.1+).
98
+ # Spedisce solo i log dell'app ≥ capture_rails_logs_min_level: soglia DEDICATA (default :warn),
99
+ # distinta da logs_min_level, così il broadcast non inonda lo stream col rumore info del framework
100
+ # (Started GET, Rendered, ... — una riga per richiesta ad alto traffico, CYRB-7). Richiede
101
+ # BroadcastLogger (Rails 7.1+).
99
102
  # `after: :load_config_initializers`: config.capture_rails_logs è impostato in
100
103
  # config/initializers/closeyourit.rb (CloseYourIt.init), che gira DOPO gli initializer dei
101
104
  # railtie. Senza questo `after:` il check leggerebbe il default (false) e il broadcast non
@@ -103,7 +106,7 @@ module CloseYourIt
103
106
  initializer "closeyourit.capture_rails_logs", after: :load_config_initializers do
104
107
  config = CloseYourIt.configuration
105
108
  if config.capture_rails_logs && ::Rails.logger.respond_to?(:broadcast_to)
106
- ::Rails.logger.broadcast_to(CloseYourIt::Rails::LogBroadcast.new(config.logs_min_level))
109
+ ::Rails.logger.broadcast_to(CloseYourIt::Rails::LogBroadcast.new(config.capture_rails_logs_min_level))
107
110
  end
108
111
  end
109
112
 
@@ -6,14 +6,21 @@ module CloseYourIt
6
6
  class Scrubber
7
7
  FILTERED = "[FILTERED]"
8
8
 
9
- # Token di chiavi sempre redatti (match per sottostringa, normalizzato). Allineato 1:1 al regex
10
- # di backend e client Dart (parità client-side) — vedi Errors/Logs::Ingest::Normalize::SENSITIVE_KEY:
11
- # /pass|secret|token|api[_-]?key|apikey|authorization|cookie|csrf|credit|card|cvv|ssn|iban/i
9
+ # Token di chiavi sempre redatti (match per sottostringa, normalizzato). Allineato al regex
10
+ # SENSITIVE_KEY di backend e client Dart (parità client-side) — vedi
11
+ # Errors/Logs::Ingest::Normalize::SENSITIVE_KEY. Copre credenziali/segreti e PII:
12
+ # /pass|secret|token|api[_-]?key|apikey|authorization|cookie|csrf|credit|card|cvv|ssn|iban|
13
+ # email|phone|telephone|mobile|dob|birth|passport|bearer|session|pin|pan/i
12
14
  # `pass` copre password/passwd/pass_code/passkey/passphrase; `cookie` copre set-cookie;
13
- # `credit`+`card` coprono credit_card. La lista letterale precedente ometteva i `pass*` bare → leak.
15
+ # `credit`+`card` coprono credit_card; `phone` copre telephone/smartphone; `birth` copre
16
+ # date_of_birth. Match per sottostringa → privilegia l'over-redaction (privacy-by-default):
17
+ # p.es. `company_name` (contiene `pan`) o `shipping` (contiene `pin`) vengono redatti — è
18
+ # accettabile, meglio redigere troppo che perdere PII.
19
+ # CYRB-3: la lista ometteva email/phone/dob → i bind delle query lente li leakavano nel pannello.
14
20
  DENYLIST = %w[
15
21
  pass secret token api_key apikey authorization
16
22
  cookie csrf credit card cvv ssn iban
23
+ email phone telephone mobile dob birth passport bearer session pin pan
17
24
  ].freeze
18
25
 
19
26
  STRING_LITERAL = /'(?:[^']|'')*'/
@@ -29,6 +29,9 @@ module CloseYourIt
29
29
  config
30
30
  )
31
31
  CloseYourIt.capture_event(event)
32
+ rescue StandardError
33
+ # La telemetria non deve mai disturbare la query ospite.
34
+ nil
32
35
  end
33
36
 
34
37
  # Breadcrumb per OGNI query non di sistema (non solo lente): SQL offuscato, niente bind.
@@ -44,6 +47,9 @@ module CloseYourIt
44
47
  message: scrubber(config).obfuscate_sql(sql),
45
48
  data: { "name" => name, "duration_ms" => duration_ms.to_f.round(2), "cached" => cached }
46
49
  )
50
+ rescue StandardError
51
+ # La telemetria non deve mai disturbare la query ospite.
52
+ nil
47
53
  end
48
54
 
49
55
  # Spinge OGNI query non di sistema nel RequestProfile dello Scope (per la detection N+1 a fine
@@ -24,7 +24,7 @@ module CloseYourIt
24
24
  CloseYourIt.stats.increment(:sent)
25
25
  else
26
26
  CloseYourIt.stats.increment(:failed)
27
- CloseYourIt.internal_logger.warn("CloseYourIt transport: HTTP #{response.code} su #{path}")
27
+ CloseYourIt.internal_logger.warn("CloseYourIt transport: HTTP #{response.code}#{error_detail(response)} su #{path}")
28
28
  end
29
29
  response
30
30
  rescue StandardError => e
@@ -37,11 +37,12 @@ module CloseYourIt
37
37
 
38
38
  def post(payload, path)
39
39
  body = JSON.generate(payload)
40
- uri = URI.parse("#{base_url}#{path}")
40
+ origin = URI.parse("#{base_url}#{path}")
41
+ uri = origin
41
42
  redirects = 0
42
43
 
43
44
  loop do
44
- response = post_once(uri, body)
45
+ response = post_once(uri, body, authorize: same_authority?(origin, uri))
45
46
  location = response["location"] if response.is_a?(Net::HTTPRedirection)
46
47
  return response unless location && redirects < MAX_REDIRECTS
47
48
 
@@ -50,14 +51,16 @@ module CloseYourIt
50
51
  end
51
52
  end
52
53
 
53
- def post_once(uri, body)
54
+ def post_once(uri, body, authorize:)
54
55
  http = Net::HTTP.new(uri.host, uri.port)
55
56
  http.use_ssl = uri.scheme == "https"
56
57
  http.open_timeout = OPEN_TIMEOUT
57
58
  http.read_timeout = READ_TIMEOUT
58
59
 
59
60
  request = Net::HTTP::Post.new(uri.request_uri)
60
- request["Authorization"] = "Bearer #{@configuration.token}"
61
+ # Il Bearer è un segreto d'ingest: non ri-inviarlo dopo un redirect verso un host diverso
62
+ # (o un downgrade https→http), per non consegnarlo a un endpoint non fidato.
63
+ request["Authorization"] = "Bearer #{@configuration.token}" if authorize
61
64
  request["Content-Type"] = "application/json"
62
65
  request["User-Agent"] = "closeyourit-ruby/#{VERSION}"
63
66
  request.body = body
@@ -71,6 +74,27 @@ module CloseYourIt
71
74
  target.relative? ? current + target : target
72
75
  end
73
76
 
77
+ # Il Bearer va ri-inviato solo se il redirect resta sullo stesso host (o la sua variante www) e
78
+ # senza downgrade https→http: evita di consegnare il segreto d'ingest a un host non fidato.
79
+ def same_authority?(origin, target)
80
+ return false if origin.scheme == "https" && target.scheme != "https"
81
+
82
+ origin.host.to_s.sub(/\Awww\./, "") == target.host.to_s.sub(/\Awww\./, "")
83
+ end
84
+
85
+ # Estrae " R…: messaggio" dall'envelope d'errore del backend, così il dev vede il codice d'errore
86
+ # nel log diagnostico (non solo lo status HTTP). Silenzioso se il body non è l'envelope atteso.
87
+ def error_detail(response)
88
+ body = JSON.parse(response.body.to_s)
89
+ error = body["error"]
90
+ return "" unless error.is_a?(Hash)
91
+
92
+ detail = [ error["code"], error["message"] ].compact.join(": ")
93
+ detail.empty? ? "" : " #{detail}"
94
+ rescue StandardError
95
+ ""
96
+ end
97
+
74
98
  def base_url
75
99
  @configuration.endpoint_url.to_s.chomp("/")
76
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -165,17 +165,28 @@ module CloseYourIt
165
165
  @app_logger ||= LogDevice.new
166
166
  end
167
167
 
168
- # Costruisce e bufferizza una voce di log strutturata (batch verso /logs, fire-and-forget). Il
169
- # `level` è normalizzato ai livelli canonici (`:warn`→`warning`, downcase; ignoto→`info`). `logger`
170
- # opzionale = nome della sorgente del log.
168
+ # API esplicita: costruisce e bufferizza una voce di log strutturata (batch verso /logs,
169
+ # fire-and-forget). Il `level` è normalizzato ai livelli canonici (`:warn`→`warning`, downcase;
170
+ # ignoto→`info`). Il keyword `logger:` = nome della sorgente del log; ogni altra keyword è un
171
+ # attributo dati. I log sotto `logs_min_level` sono scartati con la stessa mappa numerica di
172
+ # dart/js (regola standardize, not adapt — CYRB-6): niente costruzione né invio.
171
173
  # CloseYourIt.log(:info, "ordine creato", order_id: 1)
172
174
  # CloseYourIt.log(:warn, "retry", logger: "payments", attempt: 3)
173
175
  def log(level, message, logger: nil, **attributes)
176
+ emit_log(level, message, source: logger, attributes: attributes)
177
+ end
178
+
179
+ # Percorso a basso livello (gating + costruzione + buffer) con sorgente e attributes SEPARATI e
180
+ # non-ambigui: usato da `LogDevice` per trattare una chiave `logger` come attributo dati (mai come
181
+ # sorgente) e per impostare la sorgente solo via `.named` (child logger, parità dart/js — CYRB-8).
182
+ # Le app usano `CloseYourIt.log` / `CloseYourIt.logger`.
183
+ def emit_log(level, message, source: nil, attributes: {})
174
184
  return nil unless logs_enabled?
185
+ return nil if log_below_min_level?(level)
175
186
  return nil unless logs_sampled?
176
187
 
177
188
  event = LogEvent.new(message, level: level, attributes: attributes,
178
- logger: logger, configuration: configuration)
189
+ logger: source, configuration: configuration)
179
190
  log_buffer.add(event)
180
191
  nil
181
192
  end
@@ -192,6 +203,19 @@ module CloseYourIt
192
203
  nil
193
204
  end
194
205
 
206
+ # Flush di fine-vita: svuota i log bufferizzati E drena il worker asincrono, attendendo (fino a un
207
+ # timeout breve) che gli invii in volo verso l'ingest completino. Solo flushare il buffer li
208
+ # accoderebbe nel worker che verrebbe poi ucciso alla terminazione → log persi (CYRB-5). Drena
209
+ # TUTTO il worker (log + errori + metriche fire-and-forget), non solo i log. Registrato su at_exit
210
+ # da CloseYourIt.init; con `config.trap_signals` viene raggiunto anche su SIGTERM. Idempotente:
211
+ # richiamarlo è sicuro (buffer già vuoto, worker già fermo → no-op).
212
+ def shutdown
213
+ # Ordine critico: prima il buffer (accoda l'ultimo batch nel worker), poi il worker (lo drena).
214
+ @log_buffer&.shutdown
215
+ @client&.shutdown
216
+ nil
217
+ end
218
+
195
219
  # Contatori diagnostici del client (accodati/scartati/spediti/falliti).
196
220
  # CloseYourIt.stats.to_h # => { enqueued: …, dropped: …, sent: …, failed: … }
197
221
  def stats
@@ -213,6 +237,12 @@ module CloseYourIt
213
237
  enabled? && configuration.logs_enabled
214
238
  end
215
239
 
240
+ # Gating per soglia: il log è scartato se la sua severità è sotto `logs_min_level`. La severità è
241
+ # la mappa numerica di LogEvent (debug=0 … fatal=4), identica cross-SDK → filtra come dart/js.
242
+ def log_below_min_level?(level)
243
+ LogEvent.severity(level) < LogEvent.severity(configuration.logs_min_level)
244
+ end
245
+
216
246
  # Sampling indipendente dei log (1.0 = tutti, 0.0 = nessuno).
217
247
  def logs_sampled?
218
248
  rate = configuration.logs_sample_rate.to_f
@@ -222,13 +252,36 @@ module CloseYourIt
222
252
  Random.rand < rate
223
253
  end
224
254
 
225
- # Allo shutdown del processo svuota i log bufferizzati e ferma il timer (una sola registrazione).
226
- # Senza, i log sotto-batch dei processi brevi (rake/CLI) andrebbero persi all'uscita.
255
+ # Allo shutdown del processo svuota i log bufferizzati e drena il worker asincrono. Senza, i log
256
+ # sotto-batch dei processi brevi (rake/CLI) e gli ultimi eventi accodati andrebbero persi all'uscita.
257
+ # Con `config.trap_signals` intercetta anche SIGTERM, che di default salta gli at_exit (deploy/Kamal
258
+ # perderebbero il flush). Vedi #shutdown (CYRB-5).
259
+ #
260
+ # L'at_exit si registra una sola volta per processo (@shutdown_registered evita N flush da init
261
+ # multiple), ma il trap SIGTERM è valutato PRIMA del guard e a OGNI init: deve seguire la config
262
+ # CORRENTE, altrimenti una init che attiva `trap_signals` dopo una prima con la flag OFF non lo
263
+ # installerebbe mai (il guard di idempotenza ritornerebbe prima). `Signal.trap` è idempotente →
264
+ # reinstallare lo stesso handler è innocuo.
227
265
  def register_shutdown_flush
266
+ install_term_trap if configuration.trap_signals
267
+
228
268
  return if @shutdown_registered
229
269
 
230
270
  @shutdown_registered = true
231
- at_exit { @log_buffer&.shutdown }
271
+ at_exit { shutdown }
272
+ end
273
+
274
+ # SIGTERM termina il processo SENZA eseguire gli at_exit → il flush di fine-vita non girerebbe.
275
+ # Convertiamo TERM in un exit pulito: `exit` solleva SystemExit sul thread principale, che ESEGUE
276
+ # gli at_exit (incluso #shutdown) in contesto normale — niente lavoro pesante nel trap handler
277
+ # (Mutex/wait_for_termination in trap context solleverebbero ThreadError). Opt-in: sovrascrive un
278
+ # eventuale handler TERM dell'app ospite.
279
+ def install_term_trap
280
+ Signal.trap("TERM") { exit }
281
+ rescue ArgumentError, RuntimeError => e
282
+ # Contesti dove TERM non è trap-abile (piattaforma senza TERM, thread non-main): non fatale,
283
+ # resta comunque l'at_exit per le uscite normali.
284
+ internal_logger.warn("CloseYourIt: trap SIGTERM non installato (#{e.class}: #{e.message})")
232
285
  end
233
286
 
234
287
  def ignored_exception?(exception)
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessio Bussolari