closeyourit-ruby 0.6.1 → 0.8.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: 5753e09db20e85c04e6aae3b91549d07a07512a3f8af5646497be79a9295c9c7
4
- data.tar.gz: 8718d9c9ddcde3b3829fb1d40489205e1beaec9ab0f8a9f96e4a1740455bfd1a
3
+ metadata.gz: 1482102089ba9e319a50f2d3822b63fc76ddc9a63c6c629f1d7cdc53022ca362
4
+ data.tar.gz: f312feb36b22702dca3109c62f623ba4484d25caf82dc9b25c807fe89f2ea2ba
5
5
  SHA512:
6
- metadata.gz: 0b5b476a1825d5a06522e09d8b4835e07d903aa415f64768f45cd20e4ebd68e43cb41b492c99d9b520c60f187fdf452eea2ae06cc7c795d7223250f829c5c729
7
- data.tar.gz: 7965dae2df4182cff157043c24e0bd6aee8bd126f3e50958514cb4cc78274a066b59adc7908318f1ee34e82baa265cbf8b45c5e6c8848a58e3cd1a9a8f1418ec
6
+ metadata.gz: 4f6a4d467f80966599d057452927a75c17126cb01def6de0d1e365cb0fa5dcbd458122e3b860b05906d35bb307b29eed0d8e9ee9b5afb1b4008170dd3462205d
7
+ data.tar.gz: 6f4a5ee0fb8dea5b160c1447624d1f409a1a36df63700c46dc66390c9d4fc2ab932e04a1c50c2dacec9c0202f73af7b697250786bfd52f8d6303fdaaebfdbff8
data/README.md CHANGED
@@ -74,6 +74,7 @@ end
74
74
  | `async_threads` | `cpu/2` | Thread di invio; `0` = sincrono (test) |
75
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)) |
76
76
  | `slow_query_threshold_ms` | `100` | Soglia query lente |
77
+ | `excluded_query_patterns` | `solid_queue_`, `solid_cache_`, `solid_cable_` | Query da NON misurare come rallentamento — **Regexp** (pattern) o **String** (testo letterale). Vale solo per la misura: breadcrumb e profiling N+1 vedono tutto |
77
78
  | `slow_method_threshold_ms` | `200` | Soglia metodi lenti |
78
79
  | `capture_request` | `true` | Cattura il contesto HTTP della richiesta (method/url/header allowlist) |
79
80
  | `request_header_allowlist` | `Accept`, `Content-Type`, `User-Agent`, `Referer` | Header inviati (mai Authorization/Cookie) |
@@ -92,6 +93,7 @@ end
92
93
  | `logs_flush_interval` | `5` | Secondi tra i flush periodici del buffer log |
93
94
  | `capture_rails_logs` | `false` | Inoltra anche `Rails.logger` allo stream — attenzione al volume (soglia `capture_rails_logs_min_level`) |
94
95
  | `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 |
96
+ | `excluded_log_patterns` | `[]` | Righe del broadcast `Rails.logger` da scartare per **testo** — **Regexp** (pattern) o **String** (testo letterale). Per il rumore che non è un'eccezione; le eccezioni le copre già `excluded_exceptions` |
95
97
  | `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
98
  | `n_plus_one_threshold` | `10` | Ripetizioni dello stesso `[fingerprint, call-site]` in una richiesta oltre cui = `n_plus_one` |
97
99
  | `query_count_threshold` | `100` | Query totali in una richiesta oltre cui = `high_query_count` |
@@ -99,6 +101,29 @@ end
99
101
  | `slow_request_threshold_ms` | `1000` | Durata totale della richiesta (ms) oltre cui = `slow_request` |
100
102
  | `slow_external_threshold_ms` | `1000` | Durata di una singola HTTP esterna (ms) oltre cui = `slow_external_http` |
101
103
  | `capture_external_http` | `true` | Strumenta `Net::HTTP` per rilevare le HTTP esterne (effettivo solo con `detect_performance_issues`) |
104
+ | `monitor_jobs` | `true` | Misura durata e attesa in coda dei background job (ActiveJob + Sidekiq) → metriche `slow_job`/`job_queue_latency` (vedi [Metriche dei background job](#metriche-dei-background-job)) |
105
+ | `slow_job_threshold_ms` | `5000` | Durata del job (ms) oltre cui = `slow_job` |
106
+ | `job_queue_latency_threshold_ms` | `60000` | Attesa in coda (enqueue→esecuzione, ms) oltre cui = `job_queue_latency` |
107
+ | `jobs_sample_rate` | `1.0` | Frazione dei job oltre soglia effettivamente inviata (`1.0` tutti, `0.0` niente) |
108
+ | `propagate_trace_context` | `false` | Propaga il **trace context W3C** (`traceparent`/`tracestate`) alle chiamate `Net::HTTP` e mappa il `trace_id` degli eventi sul trace-id W3C entrante (vedi [Propagazione W3C](#propagazione-w3c-opt-in)) |
109
+ | `trace_propagation_allowlist` | `[]` | Host autorizzati a ricevere gli header W3C — **String** (host esatto, case-insensitive) o **Regexp** (sottodomini). Vuoto = nessuna destinazione |
110
+
111
+ ## Propagazione W3C (opt-in)
112
+
113
+ Correla le richieste Ruby e le chiamate in uscita con lo standard **[W3C Trace Context](https://www.w3.org/TR/trace-context/)**
114
+ (`traceparent`/`tracestate`) — nessun formato proprietario. È un *propagation bridge*, non un tracer
115
+ completo: adotta il contesto entrante o ne genera uno root, e lo inoltra alle chiamate `Net::HTTP`.
116
+
117
+ ```ruby
118
+ CloseYourIt.init do |c|
119
+ c.propagate_trace_context = true
120
+ c.trace_propagation_allowlist = [ "api.interno.example", /\.svc\.internal\z/ ]
121
+ end
122
+ ```
123
+
124
+ - **Ingresso**: un `traceparent` entrante valido diventa il `trace_id` degli eventi CloseYourIt → l'errore/metrica della richiesta si allinea alla traccia distribuita. Un header malformato viene ignorato (si genera un root); un servizio d'origine (senza header) parte comunque con un contesto W3C.
125
+ - **Uscita**: gli header vengono iniettati **solo** verso gli host della `trace_propagation_allowlist` (limitata per destinazione) e mai verso l'endpoint CloseYourIt stesso. Un redirect verso un host non elencato non riceve nulla.
126
+ - **Privacy**: verso host non autorizzati non parte alcun header; l'header `baggage` (che può portare contesto interno/PII) **non viene mai** emesso.
102
127
 
103
128
  ## Cosa cattura
104
129
 
@@ -172,6 +197,21 @@ Abbassala esplicitamente (es. `config.capture_rails_logs_min_level = :info`) sol
172
197
  `info` del framework. I log dei background job ereditano il `trace_id = job_id`, così log ed errore
173
198
  dello stesso job si correlano.
174
199
 
200
+ Il broadcast rispetta anche **`excluded_exceptions`**: se la riga nomina una classe esclusa non viene
201
+ inoltrata. Serve perché un'eccezione tolta dal canale errori rientrava da quello dei log — Rails la
202
+ registra comunque con `logger.error`. Con le esclusioni di default questo copre già
203
+ `ActionController::RoutingError`, cioè le favicon mancanti e le scansioni dei bot, che in produzione
204
+ sono la voce di gran lunga più numerosa dello stream. Per il rumore ripetuto che **non** è
205
+ un'eccezione c'è `excluded_log_patterns`:
206
+
207
+ ```ruby
208
+ config.excluded_log_patterns = [ /SolidQueue-[\d.]+ Error in thread/, "Rendered layout" ]
209
+ ```
210
+
211
+ Le stringhe valgono come testo letterale (vengono escapate), i `Regexp` come pattern. Entrambe le
212
+ liste gatano **solo** il mirror di `Rails.logger`: un `CloseYourIt.log` scritto di proposito non viene
213
+ mai silenziato.
214
+
175
215
  #### Flush di fine-vita
176
216
 
177
217
  Il buffer log viene svuotato automaticamente allo **shutdown del processo**: `CloseYourIt.init` registra
@@ -180,6 +220,31 @@ buffer), così i log sotto-batch di rake/CLI non vanno persi all'uscita. `SIGTER
180
220
  termina il processo **senza** eseguire gli `at_exit`: imposta `trap_signals = true` per intercettarlo e
181
221
  convertirlo in un exit pulito, oppure chiama `CloseYourIt.shutdown` esplicitamente prima di terminare.
182
222
 
223
+ #### Server che forkano (Puma cluster, Sidekiq, Unicorn)
224
+
225
+ I server che precaricano l'app (`preload_app!`) inizializzano la gemma nel processo **master**, poi
226
+ forkano i worker. Il worker pool asincrono e il timer del buffer log sono **thread**, e i thread non
227
+ sopravvivono a un `fork`: nel figlio quelle risorse sarebbero ereditate ma inerti e gli eventi non
228
+ partirebbero mai. La gemma **rileva il cambio di PID da sé** (lazy, al primo evento del figlio) e ricrea
229
+ worker e buffer nel processo corrente — nella maggior parte dei casi non devi fare nulla.
230
+
231
+ Per ricrearle **subito** dopo il fork (invece di attendere il primo evento) chiama
232
+ `CloseYourIt.after_fork` nel worker hook del tuo server:
233
+
234
+ ```ruby
235
+ # Puma (config/puma.rb)
236
+ on_worker_boot { CloseYourIt.after_fork }
237
+
238
+ # Sidekiq (config/initializers/sidekiq.rb)
239
+ Sidekiq.configure_server { |config| config.on(:startup) { CloseYourIt.after_fork } }
240
+
241
+ # Unicorn (config/unicorn.rb)
242
+ after_fork { |server, worker| CloseYourIt.after_fork }
243
+ ```
244
+
245
+ `CloseYourIt.after_fork` abbandona le risorse ereditate **senza drenarle** (non tenta un join sui thread
246
+ del padre e non riflusha il buffer, che rispedirebbe eventi del padre) ed è sicuro da chiamare più volte.
247
+
183
248
  ### Contesto, breadcrumbs e messaggi (manuale)
184
249
 
185
250
  ```ruby
@@ -201,6 +266,21 @@ all'evento d'errore catturato nello stesso contesto di esecuzione.
201
266
  Il Railtie si iscrive a `sql.active_record`: ogni query oltre `slow_query_threshold_ms` (esclusi
202
267
  `SCHEMA`/`CACHE`) viene inviata come `slow_query` alla pipeline metriche. Lo SQL è **offuscato** (bind esclusi).
203
268
 
269
+ Sono escluse anche le query che combaciano con `excluded_query_patterns` — di default le tabelle di
270
+ servizio del Solid stack (`solid_queue_`, `solid_cache_`, `solid_cable_`). Sono infrastruttura del
271
+ framework: una loro query lenta non si corregge leggendo il proprio repo, dice solo che il database è
272
+ in contesa, e in compenso sommerge le query dell'applicazione (su un backend reale erano il **62%**
273
+ dei campioni). Per misurarle davvero azzera la lista:
274
+
275
+ ```ruby
276
+ config.excluded_query_patterns = [] # misura tutto
277
+ config.excluded_query_patterns += [ "pg_stat_activity" ] # o aggiungi le tue
278
+ ```
279
+
280
+ Il filtro vale solo per la **misura**: le breadcrumb «quali query prima del crash» e il profiling N+1
281
+ continuano a vedere tutte le query, perché lì una tabella di servizio letta molte volte è essa stessa
282
+ un sintomo.
283
+
204
284
  ### Metodi lenti → metriche
205
285
 
206
286
  ```ruby
@@ -257,6 +337,36 @@ tutte ritoccabili.
257
337
  > switch. (Pendant server-side dei verdetti `performance_issue` di closeyourit-js: qui i `subtype`
258
338
  > sono quelli del backend Rails — N+1, query count, request/HTTP esterne lente.)
259
339
 
340
+ ## Metriche dei background job
341
+
342
+ Oltre agli **errori** dei job (sezione precedente), la gemma misura **automaticamente** durata di
343
+ esecuzione e attesa in coda dei background job e, oltre le soglie, invia metriche `performance_issue`
344
+ sulla pipeline metriche (`/api/v1/projects/:id/metrics`). Funziona con **ActiveJob** (via notifiche
345
+ `ActiveSupport::Notifications`) e con **Sidekiq** (server middleware), senza strumentazione manuale.
346
+ Due `subtype`:
347
+
348
+ - **`slow_job`** — la durata del `perform` supera `slow_job_threshold_ms` (default 5000 ms).
349
+ - **`job_queue_latency`** — l'attesa in coda (dall'enqueue all'inizio dell'esecuzione) supera
350
+ `job_queue_latency_threshold_ms` (default 60000 ms): coda intasata o worker insufficienti.
351
+
352
+ Ogni metrica porta la **label** (nome della classe del job), la **queue**, l'**adapter**
353
+ (`active_job`/`sidekiq`), l'**attempt** (numero di esecuzione: rende visibili i **retry**), la
354
+ **release** e il **trace_id** (= `job_id` / `jid`, che correla la metrica a log ed errori dello stesso
355
+ job). **Gli argomenti del job non vengono mai inviati** (solo la label).
356
+
357
+ È **ON di default** (a differenza dei verdetti performance opt-in): la visibilità dei job lenti, in
358
+ ritardo o ripetutamente ritentati non deve richiedere configurazione in ogni app, e l'overhead è basso
359
+ (una notifica per job, non il profiling di ogni query). Il rumore è tenuto a bada dalle **soglie** (i
360
+ job normali restano sotto soglia → nessuna metrica) e dal **sampling** (`jobs_sample_rate`, applicato
361
+ solo ai job già oltre soglia). Per spegnerlo del tutto:
362
+
363
+ ```ruby
364
+ CloseYourIt.init do |c|
365
+ # …endpoint_url / token / project_id…
366
+ c.monitor_jobs = false
367
+ end
368
+ ```
369
+
260
370
  ## Privacy & PII
261
371
 
262
372
  Privacy-by-default (`send_pii = false`). In sintesi:
@@ -277,17 +387,45 @@ Privacy-by-default (`send_pii = false`). In sintesi:
277
387
  Il trasporto è fire-and-forget: non solleva mai e non blocca la request. Per non lasciare
278
388
  fallimenti silenziosi, ogni risposta HTTP non-2xx (es. `401` token errato, `404` progetto
279
389
  inesistente) viene loggata a `warn`, così come gli eventi scartati a coda piena. I contatori
280
- sono ispezionabili a runtime:
390
+ sono ispezionabili a runtime (`snapshot` è un alias di `to_h`):
281
391
 
282
392
  ```ruby
283
393
  CloseYourIt.stats.to_h
284
- # => { enqueued: 128, dropped: 0, sent: 126, failed: 2 }
394
+ # => { enqueued: 128, dropped: 1, sent: 125, failed: 2, timeout: 1 }
285
395
  ```
286
396
 
287
397
  - `enqueued` — eventi accettati per l'invio
288
- - `dropped` — scartati perché la coda async era piena (mai backpressure)
398
+ - `dropped` — scartati prima dell'invio: coda async piena, `before_send` → `nil`/eccezione, sampling
289
399
  - `sent` — risposta HTTP 2xx
290
400
  - `failed` — errore di rete o status non-2xx (vedi i log a `warn`)
401
+ - `timeout` — sotto-conteggio di `failed`: fallimenti per timeout di rete (connettività)
402
+
403
+ ### Hook `on_diagnostic` (opt-in)
404
+
405
+ Per osservare il ciclo di vita di ogni evento senza abilitare log rumorosi, registra un hook. È
406
+ invocato a ogni tappa — `:enqueue`, `:send`, `:drop`, `:timeout`, `:shutdown` — con dettagli privi
407
+ di dati sensibili:
408
+
409
+ ```ruby
410
+ CloseYourIt.init do |c|
411
+ # ...
412
+ c.on_diagnostic = ->(event, details) do
413
+ StatsD.increment("closeyourit.#{event}", tags: details) # es. inoltra a metriche interne
414
+ end
415
+ end
416
+ ```
417
+
418
+ - `:enqueue` — `{ path: … }` accodato per l'invio
419
+ - `:send` — `{ status: 202 }` consegnato (2xx)
420
+ - `:drop` — scartato: `{ reason: :queue_full | :before_send | :sampled | :error | :response | :network, … }`
421
+ - `:timeout` — `{ error: "Net::OpenTimeout" }` timeout di rete
422
+ - `:shutdown` — `{ stats: {…} }` snapshot alla terminazione del processo, emesso una sola volta per
423
+ sessione (best-effort: invii ancora in volo oltre il breve timeout di drain possono non esservi
424
+ riflessi — il drain non blocca l'uscita)
425
+
426
+ L'hook è **locale e non ricorsivo**: non invia mai telemetria, e una notifica emessa dal suo interno
427
+ è soppressa — non può innescare loop di auto-monitoraggio. Un hook che solleva è isolato (loggato,
428
+ mai propagato nell'app). Tienilo veloce: gira sincrono nel thread chiamante, come `before_send`.
291
429
 
292
430
  ## Sviluppo
293
431
 
@@ -26,6 +26,7 @@ module CloseYourIt
26
26
  unless accepted
27
27
  CloseYourIt.stats.increment(:dropped)
28
28
  CloseYourIt.internal_logger.warn("CloseYourIt background worker: coda piena, evento scartato")
29
+ CloseYourIt.notify_diagnostic(:drop, reason: :queue_full)
29
30
  end
30
31
 
31
32
  accepted
@@ -21,11 +21,19 @@ module CloseYourIt
21
21
  def capture_event(event)
22
22
  payload = event.to_h
23
23
  payload = @configuration.before_send.call(payload) if @configuration.before_send
24
- return nil if payload.nil?
24
+ if payload.nil?
25
+ # before_send ha scartato l'evento (ritorna nil): scarto voluto, reso visibile (CYRB-12).
26
+ CloseYourIt.stats.increment(:dropped)
27
+ CloseYourIt.notify_diagnostic(:drop, reason: :before_send)
28
+ return nil
29
+ end
25
30
 
26
31
  path = event.ingest_path(@configuration.project_id)
27
32
  accepted = @worker.perform { @transport.send_event(payload, path: path) }
28
- CloseYourIt.stats.increment(:enqueued) if accepted
33
+ if accepted
34
+ CloseYourIt.stats.increment(:enqueued)
35
+ CloseYourIt.notify_diagnostic(:enqueue, path: path)
36
+ end
29
37
  payload
30
38
  rescue StandardError => e
31
39
  # La telemetria non deve MAI propagare nel path dell'app ospite: capture_event è invocato dal
@@ -33,6 +41,8 @@ module CloseYourIt
33
41
  # malformato) e before_send sono valutati qui in modo sincrono → se sollevano, assorbiamo,
34
42
  # logghiamo e scartiamo l'evento invece di disturbare la query ospite. Vedi CYRB-2.
35
43
  CloseYourIt.internal_logger.error("CloseYourIt client: #{e.class}: #{e.message}")
44
+ CloseYourIt.stats.increment(:dropped)
45
+ CloseYourIt.notify_diagnostic(:drop, reason: :error, error: e.class.name)
36
46
  nil
37
47
  end
38
48
 
@@ -45,13 +55,25 @@ module CloseYourIt
45
55
  return nil if events.nil? || events.empty?
46
56
 
47
57
  payloads = events.map(&:to_h)
48
- payloads = payloads.filter_map { |payload| @configuration.before_send.call(payload) } if @configuration.before_send
58
+ if @configuration.before_send
59
+ kept = payloads.filter_map { |payload| @configuration.before_send.call(payload) }
60
+ # I log che before_send porta a nil sono scarti voluti: contabilizzali come gli errori/metriche
61
+ # (parità con #capture_event), altrimenti sparirebbero silenziosamente dai contatori (CYRB-12).
62
+ (payloads.size - kept.size).times do
63
+ CloseYourIt.stats.increment(:dropped)
64
+ CloseYourIt.notify_diagnostic(:drop, reason: :before_send)
65
+ end
66
+ payloads = kept
67
+ end
49
68
  return nil if payloads.empty?
50
69
 
51
70
  path = events.first.ingest_path(@configuration.project_id)
52
71
  payloads.each_slice(LOGS_MAX_BATCH) do |chunk|
53
72
  accepted = @worker.perform { @transport.send_event(chunk, path: path) }
54
- CloseYourIt.stats.increment(:enqueued) if accepted
73
+ next unless accepted
74
+
75
+ CloseYourIt.stats.increment(:enqueued)
76
+ CloseYourIt.notify_diagnostic(:enqueue, path: path, batch: chunk.size)
55
77
  end
56
78
  payloads
57
79
  end
@@ -15,7 +15,18 @@ module CloseYourIt
15
15
  # Header HTTP catturati nel contesto request (mai Authorization/Cookie → niente PII/segreti).
16
16
  DEFAULT_REQUEST_HEADER_ALLOWLIST = %w[Accept Content-Type User-Agent Referer].freeze
17
17
 
18
- attr_accessor :endpoint_url, :token, :project_id, :environment, :before_send,
18
+ # Tabelle di servizio del Solid stack: sono infrastruttura del framework, non codice
19
+ # dell'applicazione. Una loro query lenta non si può correggere leggendo il proprio repo — dice
20
+ # solo che il database è in contesa, cosa che le query dell'app raccontano già. Escluse per
21
+ # DEFAULT dalla misura dei rallentamenti perché altrimenti la sommergono: su closeyourit-rails il
22
+ # 2026-07-30 erano circa 2.740 campioni su 4.400 (62%), tutti fra i 250 e i 655 ms (CYRB-18).
23
+ DEFAULT_EXCLUDED_QUERY_PATTERNS = [
24
+ /\bsolid_queue_/,
25
+ /\bsolid_cache_/,
26
+ /\bsolid_cable_/
27
+ ].freeze
28
+
29
+ attr_accessor :endpoint_url, :token, :project_id, :environment, :before_send, :on_diagnostic,
19
30
  :async_threads, :background_worker_max_queue,
20
31
  :slow_query_threshold_ms, :slow_method_threshold_ms,
21
32
  :send_pii, :obfuscate_sql, :send_server_name,
@@ -28,9 +39,12 @@ module CloseYourIt
28
39
  :capture_rails_logs, :logs_min_level, :capture_rails_logs_min_level,
29
40
  :detect_performance_issues, :n_plus_one_threshold, :query_count_threshold,
30
41
  :query_time_threshold_ms, :slow_request_threshold_ms, :slow_external_threshold_ms,
31
- :capture_external_http, :trap_signals
42
+ :capture_external_http, :trap_signals,
43
+ :monitor_jobs, :slow_job_threshold_ms, :job_queue_latency_threshold_ms,
44
+ :jobs_sample_rate, :propagate_trace_context
32
45
  attr_writer :release, :project_root
33
- attr_reader :excluded_exceptions, :filter_parameters, :scrub_message_patterns
46
+ attr_reader :excluded_exceptions, :excluded_log_patterns, :excluded_query_patterns,
47
+ :filter_parameters, :scrub_message_patterns, :trace_propagation_allowlist
34
48
 
35
49
  def initialize
36
50
  @endpoint_url = ENV.fetch("CLOSEYOURIT_ENDPOINT_URL", nil)
@@ -42,6 +56,12 @@ module CloseYourIt
42
56
  @excluded_exceptions = DEFAULT_EXCLUDED_EXCEPTIONS.dup
43
57
  @before_send = nil
44
58
 
59
+ # Hook diagnostico opt-in: `->(event, details) { ... }` invocato a ogni tappa del ciclo di vita
60
+ # di un evento (:enqueue, :send, :drop, :timeout, :shutdown) con dettagli privi di dati sensibili
61
+ # (es. `{ reason: :queue_full }`, `{ status: 429 }`). Locale e non ricorsivo: non invia telemetria
62
+ # e non può innescare loop di auto-monitoraggio (vedi CloseYourIt.notify_diagnostic, CYRB-12).
63
+ @on_diagnostic = nil
64
+
45
65
  @async_threads = default_threads
46
66
  @background_worker_max_queue = 30
47
67
 
@@ -52,6 +72,9 @@ module CloseYourIt
52
72
 
53
73
  @slow_query_threshold_ms = 100
54
74
  @slow_method_threshold_ms = 200
75
+ # Query da NON misurare come rallentamento (match sul testo SQL). Default: le tabelle di servizio
76
+ # del Solid stack. Chi vuole misurarle davvero azzera la lista.
77
+ @excluded_query_patterns = DEFAULT_EXCLUDED_QUERY_PATTERNS.dup
55
78
 
56
79
  @send_pii = false
57
80
  @obfuscate_sql = true
@@ -98,6 +121,10 @@ module CloseYourIt
98
121
  # inonderebbe lo stream con decine di migliaia di log-entry/min (CYRB-7). Chi vuole anche gli info
99
122
  # del framework la abbassa esplicitamente (es. :info).
100
123
  @capture_rails_logs_min_level = :warn
124
+ # Rumore del broadcast Rails.logger che NON è un'eccezione, e quindi excluded_exceptions non può
125
+ # coprire: righe ripetute del framework o di una gemma. Regexp sul testo del messaggio, default
126
+ # vuoto. Vale SOLO per il broadcast automatico, mai per CloseYourIt.log esplicito.
127
+ @excluded_log_patterns = []
101
128
 
102
129
  # Performance issue detection (verdetti aggregati: N+1, slow request, HTTP esterne lente).
103
130
  # OPT-IN, default OFF: profila OGNI query della richiesta → overhead non trascurabile, va attivato
@@ -110,6 +137,26 @@ module CloseYourIt
110
137
  @slow_external_threshold_ms = 1000 # singola chiamata HTTP esterna
111
138
  @capture_external_http = true # strumenta Net::HTTP (solo se detect_performance_issues)
112
139
 
140
+ # Metriche dei background job: durata di esecuzione e attesa in coda (queue latency) per ActiveJob
141
+ # e Sidekiq. ON di default (a differenza di detect_performance_issues): la visibilità dei job
142
+ # lenti/in ritardo/ritentati non deve richiedere strumentazione manuale in ogni app (CYRB-14), e
143
+ # l'overhead è basso — una notifica per job, non il profiling di ogni query. Il rumore è tenuto a
144
+ # bada dalle soglie (job normali sotto soglia = niente metrica) e dal sampling. La label è il nome
145
+ # della classe del job; gli argomenti non vengono MAI inviati.
146
+ @monitor_jobs = true
147
+ @slow_job_threshold_ms = 5000 # durata del perform oltre cui = slow_job
148
+ @job_queue_latency_threshold_ms = 60_000 # attesa enqueue→esecuzione oltre cui = job_queue_latency
149
+ @jobs_sample_rate = 1.0 # frazione dei candidati oltre soglia effettivamente inviata
150
+
151
+ # Propagazione W3C trace context (traceparent/tracestate) verso i servizi esterni chiamati via
152
+ # Net::HTTP. OPT-IN, default OFF: iniettare header d'uscita attraversa un trust boundary e va deciso
153
+ # per-app. È limitata PER DESTINAZIONE dalla allowlist (host esatti, case-insensitive, o Regexp per i
154
+ # sottodomini); lista vuota = nessuna destinazione. Mai verso host non elencati, mai come `baggage`
155
+ # (che può portare PII). In ingresso un traceparent valido diventa il trace_id degli eventi
156
+ # CloseYourIt → gli errori/metriche della richiesta si correlano alla traccia distribuita (CYRB-15).
157
+ @propagate_trace_context = false
158
+ @trace_propagation_allowlist = []
159
+
113
160
  # Radice del progetto: base per il filename relativo dei frame (culprit cross-SDK). Lazy:
114
161
  # auto-rilevata da Rails.root o Dir.pwd al primo accesso se non impostata esplicitamente.
115
162
  @project_root = nil
@@ -123,10 +170,28 @@ module CloseYourIt
123
170
  @excluded_exceptions = Array(list).map { |item| item.is_a?(Regexp) ? item : item.to_s }
124
171
  end
125
172
 
173
+ # Pattern del broadcast Rails.logger da scartare. Le stringhe diventano Regexp (match letterale
174
+ # sul testo): chi scrive `config.excluded_log_patterns = ["Rendered layout"]` intende quello.
175
+ def excluded_log_patterns=(list)
176
+ @excluded_log_patterns = Array(list).map { |item| item.is_a?(Regexp) ? item : Regexp.new(Regexp.escape(item.to_s)) }
177
+ end
178
+
179
+ # Query da non misurare. Stessa normalizzazione di excluded_log_patterns: String = testo letterale
180
+ # (un nome di tabella si scrive così, non come pattern), Regexp = pattern.
181
+ def excluded_query_patterns=(list)
182
+ @excluded_query_patterns = Array(list).map { |item| item.is_a?(Regexp) ? item : Regexp.new(Regexp.escape(item.to_s)) }
183
+ end
184
+
126
185
  def filter_parameters=(list)
127
186
  @filter_parameters = Array(list)
128
187
  end
129
188
 
189
+ # Destinazioni autorizzate a ricevere gli header di trace W3C. String = host esatto (match
190
+ # case-insensitive), Regexp = pattern (per sottodomini/famiglie di host). Lista vuota = nessuno.
191
+ def trace_propagation_allowlist=(list)
192
+ @trace_propagation_allowlist = Array(list)
193
+ end
194
+
130
195
  def scrub_message_patterns=(list)
131
196
  @scrub_message_patterns = Array(list)
132
197
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../event"
5
+
6
+ module CloseYourIt
7
+ # Metrica `kind=performance_issue` per un background job che ha superato una soglia di DURATA
8
+ # (`subtype=slow_job`) o di ATTESA in coda (`subtype=job_queue_latency`). Spedita sulla pipeline
9
+ # metriche (`/api/v1/projects/:id/metrics`), come slow_query/slow_method e i verdetti performance.
10
+ #
11
+ # `label` è il nome della CLASSE del job — mai gli argomenti (privacy: come slow_method invia solo
12
+ # label/durata). `attempt` rende visibili i retry; `trace_id` (= job_id ActiveJob / jid Sidekiq)
13
+ # correla la metrica a log ed errori dello stesso job. `duration_ms` è il valore misurato del subtype
14
+ # (durata del perform per slow_job, attesa in coda per job_queue_latency) — stessa convenzione del
15
+ # Rollup. I campi nil vengono omessi.
16
+ class JobMetricEvent < Event
17
+ def initialize(attrs, configuration)
18
+ super(configuration)
19
+ @attrs = attrs
20
+ end
21
+
22
+ def to_h
23
+ compact(
24
+ "kind" => "performance_issue",
25
+ "subtype" => @attrs[:subtype],
26
+ "sample_id" => SecureRandom.uuid,
27
+ "duration_ms" => @attrs[:duration_ms]&.round(2),
28
+ "occurred_at" => @occurred_at,
29
+ "environment" => environment,
30
+ "release" => @configuration.release,
31
+ "trace_id" => @attrs[:trace_id],
32
+ "label" => @attrs[:job_class],
33
+ "queue" => @attrs[:queue],
34
+ "adapter" => @attrs[:adapter],
35
+ "attempt" => @attrs[:attempt],
36
+ "sdk" => sdk
37
+ )
38
+ end
39
+
40
+ def ingest_path(project_id)
41
+ "/api/v1/projects/#{project_id}/metrics"
42
+ end
43
+ end
44
+ end
@@ -37,6 +37,12 @@ module CloseYourIt
37
37
  # before_send che solleva su UN evento non deve propagare nell'app né uccidere il TimerTask
38
38
  # (che altrimenti smette di flushare → log accumulati e persi). Ingoiato e loggato.
39
39
  CloseYourIt.internal_logger.error("CloseYourIt log buffer: #{e.class}: #{e.message}")
40
+ # Il batch è già stato drenato: se flush_logs solleva, quei log sono persi. Contabilizzali come
41
+ # scarti per non lasciare un fallimento silenzioso (CYRB-12). `batch.to_a` è nil-safe.
42
+ batch.to_a.size.times do
43
+ CloseYourIt.stats.increment(:dropped)
44
+ CloseYourIt.notify_diagnostic(:drop, reason: :error)
45
+ end
40
46
  end
41
47
 
42
48
  def shutdown
@@ -3,17 +3,45 @@
3
3
  module CloseYourIt
4
4
  module Rails
5
5
  # Incluso in ActiveJob::Base (via railtie `on_load(:active_job)`): cattura gli errori dei job
6
- # (oggi persi) con il contesto del job, poi ri-solleva. La logica vive in `.monitor` per essere
6
+ # (oggi persi) con il contesto del job. La logica vive in `.monitor`/`.report_discarded` per essere
7
7
  # testabile senza ActiveSupport/ActiveJob.
8
+ #
9
+ # CYRB-19: `around_perform` gira DENTRO `perform_now`, mentre `retry_on`/`discard_on` (rescue_from)
10
+ # sono valutati FUORI, dopo i callback. Catturare nell'around_perform significa quindi segnalare
11
+ # OGNI tentativo — anche quelli che verranno ritentati con successo — perché il reporter vede
12
+ # l'errore prima che il retry possa zittirlo, e ogni tentativo solleva una nuova istanza (la
13
+ # deduplica interna, per-istanza, non interviene). Su ActiveJob 7.1+ deleghiamo la segnalazione ad
14
+ # `after_discard`, che Rails invoca al fallimento definitivo (retry_on esauriti o eccezione non
15
+ # gestita), mai sui tentativi che `retry_on` ritenta → una sola occorrenza per job.
16
+ #
17
+ # Limiti noti (per costruzione di ActiveJob, non del client):
18
+ # - `after_discard` NON scatta per gli errori intercettati da un `rescue_from` custom: sono gestiti
19
+ # dall'app, quindi non li segnaliamo più come "non gestiti" (prima lo facevamo, impropriamente).
20
+ # - I retry a livello di ADAPTER (es. Sidekiq) SENZA `retry_on` risollevano l'errore non gestito:
21
+ # `after_discard` scatta a ogni esecuzione, quindi lì la de-duplicazione per tentativo non si
22
+ # applica (comportamento invariato rispetto a prima).
23
+ # Sulle versioni prive di `after_discard` restiamo al fallback legacy (cattura nell'around_perform).
8
24
  module ActiveJobExtension
25
+ # Scope arricchito durante il `perform` tramandato ad `after_discard`. Legato all'ISTANZA del job
26
+ # (ogni retry ne crea una nuova, deserializzata) → vive esattamente quanto serve, niente bleed tra
27
+ # job o thread; il reset di fine `perform` lo sgancia solo dallo storage, non muta l'oggetto.
28
+ STASHED_SCOPE_IVAR = :@__closeyourit_stashed_scope
29
+
9
30
  def self.included(base)
10
31
  base.around_perform do |job, block|
11
32
  CloseYourIt::Rails::ActiveJobExtension.monitor(job) { block.call }
12
33
  end
34
+
35
+ return unless base.respond_to?(:after_discard)
36
+
37
+ base.after_discard do |job, exception|
38
+ CloseYourIt::Rails::ActiveJobExtension.report_discarded(job, exception)
39
+ end
13
40
  end
14
41
 
15
- # Esegue il job arricchendo lo scope con tag/context; cattura l'errore (handled:false) e
16
- # ri-solleva; resetta lo scope a fine job (no bleed tra job sullo stesso thread).
42
+ # Esegue il job arricchendo lo scope con tag/context; resetta lo scope a fine job (no bleed tra
43
+ # job sullo stesso thread). Su ActiveJob 7.1+ NON cattura l'errore (lo fa `after_discard` solo al
44
+ # fallimento definitivo): qui tramanda lo scope al job e ri-solleva. Sul fallback legacy cattura.
17
45
  def self.monitor(job)
18
46
  return yield unless CloseYourIt.configuration.report_active_job_errors
19
47
 
@@ -21,13 +49,41 @@ module CloseYourIt
21
49
  apply_job_scope(job)
22
50
  yield
23
51
  rescue Exception => e # rubocop:disable Lint/RescueException
24
- CloseYourIt.capture_exception(e, handled: false)
52
+ if report_on_discard?(job)
53
+ job.instance_variable_set(STASHED_SCOPE_IVAR, CloseYourIt::Scope.current)
54
+ else
55
+ CloseYourIt.capture_exception(e, handled: false)
56
+ end
25
57
  raise
26
58
  ensure
27
59
  CloseYourIt::Scope.reset!
28
60
  end
29
61
  end
30
62
 
63
+ # Aggancio `after_discard` (ActiveJob 7.1+): il job è definitivamente fallito, quindi segnaliamo
64
+ # l'errore UNA sola volta (handled:false). Riprendiamo lo scope arricchito durante il `perform`
65
+ # (tramandato da `monitor`) così il report conserva breadcrumb/tag/contesti raccolti nel job;
66
+ # `apply_job_scope` rinfresca i campi standard con `executions` finale senza perdere i custom.
67
+ def self.report_discarded(job, exception)
68
+ return unless CloseYourIt.configuration.report_active_job_errors
69
+
70
+ begin
71
+ stashed = job.instance_variable_get(STASHED_SCOPE_IVAR)
72
+ CloseYourIt::Scope.current = stashed if stashed
73
+ apply_job_scope(job)
74
+ CloseYourIt.capture_exception(exception, handled: false)
75
+ ensure
76
+ job.remove_instance_variable(STASHED_SCOPE_IVAR) if job.instance_variable_defined?(STASHED_SCOPE_IVAR)
77
+ CloseYourIt::Scope.reset!
78
+ end
79
+ end
80
+
81
+ # true quando ActiveJob espone `after_discard` (7.1+): la segnalazione è delegata lì, così
82
+ # `monitor` non cattura i tentativi intermedi. false → fallback legacy (cattura nell'around_perform).
83
+ def self.report_on_discard?(job)
84
+ job.class.respond_to?(:after_discard)
85
+ end
86
+
31
87
  def self.apply_job_scope(job)
32
88
  CloseYourIt.set_tag("job.class", job.class.name)
33
89
  CloseYourIt.set_tag("job.queue", job.queue_name) if job.respond_to?(:queue_name)
@@ -18,13 +18,20 @@ module CloseYourIt
18
18
  self.level = LEVEL_BY_SYMBOL.fetch(min_level.to_sym, 2)
19
19
  end
20
20
 
21
- # Sovrascrive il punto unico di ::Logger: filtra per soglia e re-inoltra a CloseYourIt.log.
21
+ # Sovrascrive il punto unico di ::Logger: filtra per soglia e per esclusioni, poi re-inoltra a
22
+ # CloseYourIt.log. Il secondo filtro esiste perché la soglia da sola non distingue il rumore: una
23
+ # `ActionController::RoutingError` da favicon mancante è un `logger.error` come un altro, e senza
24
+ # controllare il TESTO rientrava dal canale log dopo essere stata esclusa da quello degli errori
25
+ # (CYRB-17). Il gating vive qui e non in `CloseYourIt.log`: quel metodo serve anche il dev che
26
+ # scrive un log di proposito, e le sue righe non si silenziano.
22
27
  def add(severity, message = nil, progname = nil)
23
28
  severity ||= ::Logger::UNKNOWN
24
29
  return true if severity < level
25
30
 
26
31
  text = message || (block_given? ? yield : nil) || progname
27
- CloseYourIt.log(SEVERITY_LEVELS.fetch(severity, "info"), text) unless text.nil?
32
+ return true if text.nil? || CloseYourIt.ignored_log_message?(text)
33
+
34
+ CloseYourIt.log(SEVERITY_LEVELS.fetch(severity, "info"), text)
28
35
  true
29
36
  end
30
37
  end
@@ -12,6 +12,9 @@ module CloseYourIt
12
12
  module NetHTTPPatch
13
13
  def request(req, body = nil, &block)
14
14
  config = CloseYourIt.configuration
15
+ # Propagazione W3C: indipendente dal profiling (ha il suo opt-in), va fatta PRIMA del round-trip
16
+ # perché aggiunge header alla richiesta in uscita.
17
+ inject_trace_context(config, req)
15
18
  return super unless config.detect_performance_issues && config.capture_external_http
16
19
 
17
20
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -25,6 +28,53 @@ module CloseYourIt
25
28
 
26
29
  private
27
30
 
31
+ # Gestisce gli header di trace W3C sulla richiesta in uscita (solo con la propagazione opt-in ON).
32
+ # Verso una destinazione autorizzata inietta traceparent/tracestate; verso qualsiasi altra li
33
+ # RIMUOVE. La rimozione è la difesa contro il leak su redirect cross-host: se lo stesso oggetto
34
+ # request viene riusato per seguire un redirect verso un host non in allowlist, gli header della
35
+ # chiamata precedente non devono sopravvivere ("host esterni non ricevono header interni" — CYRB-15).
36
+ # baggage non viene mai né letto né emesso. Difensivo: mai solleva per colpa della propagazione.
37
+ def inject_trace_context(config, req)
38
+ return unless config.propagate_trace_context
39
+
40
+ context = deliverable_context(config)
41
+ if context.nil?
42
+ strip_trace_headers(req)
43
+ else
44
+ context.headers.each { |name, value| req[name] = value }
45
+ end
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ # Il trace context da consegnare a QUESTA destinazione, o nil se non va propagato nulla: host
51
+ # assente, endpoint CloseYourIt stesso (niente auto-propagazione), destinazione fuori allowlist,
52
+ # o scope privo di contesto.
53
+ def deliverable_context(config)
54
+ host = address
55
+ return nil if host.nil? || own_endpoint?(config, host)
56
+ return nil unless destination_allowed?(config, host)
57
+
58
+ CloseYourIt::Scope.current.trace_context
59
+ end
60
+
61
+ # La destinazione è autorizzata a ricevere il trace context? String = host esatto (case-insensitive),
62
+ # Regexp = pattern (sottodomini/famiglie). Lista vuota → sempre false (nessuna destinazione).
63
+ def destination_allowed?(config, host)
64
+ config.trace_propagation_allowlist.any? do |pattern|
65
+ pattern.is_a?(Regexp) ? pattern.match?(host) : pattern.to_s.casecmp?(host)
66
+ end
67
+ end
68
+
69
+ # Rimuove gli header di trace W3C che una chiamata precedente sullo stesso oggetto request possa
70
+ # aver lasciato. Solo i nostri header, mai altro; no-op se il request non li supporta.
71
+ def strip_trace_headers(req)
72
+ return unless req.respond_to?(:delete)
73
+
74
+ req.delete("traceparent")
75
+ req.delete("tracestate")
76
+ end
77
+
28
78
  def record_external(config, req, duration_ms)
29
79
  host = address
30
80
  return if host.nil? || own_endpoint?(config, host)