closeyourit-ruby 0.6.0 → 0.7.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: c80dd7130394211bc1c0ebbf7a13bbe9906951b667734380bdf38aac53f194ae
4
- data.tar.gz: 9adde552df65cee4cd32d47161bdd84fbe5a69c06698b9fc5c9cfd5a1a2a1088
3
+ metadata.gz: '083ced8226884d6ceed86ae4da2a17cb7f9bfdfa5f3cd6073ac09268c4545d6e'
4
+ data.tar.gz: 75b18abef8b8c45ae14647c4bc102276286a2c71c1056d37274d0fe350f20555
5
5
  SHA512:
6
- metadata.gz: 1f7cc16b574bfc124e9f9f49037a7045dc30a7125da890f78ae6c7c36712a418da61dbd2170a12a8d34be35ea5fc8fb96568dc58e1e573677fe67afb902c071e
7
- data.tar.gz: aa9871930fa03617485c993e2f84db54aeb23ecd092c8a2a15a463e9dcda75c710d3cadef95f14081bbe3844cca50f4215ef0c5b40cb149e205ff1d6e701a575
6
+ metadata.gz: c9285e453091b8f89bc360e1a274fc72fa7b312cd2fd2ac7f8dddb0636a7a49e6f7782f0af5b8e2cb20275e841566731fe48174dddf6f502cb9eb92646bae216
7
+ data.tar.gz: 1bcd7478c0d2d5572c0fa761e0b16a6bacffef89bd227a36fae117cea468fdd538bf9f011d839fa378a29871a1b2e8efdfc3ef2c467df684096eff585206a7d3
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,10 @@ 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) |
102
108
 
103
109
  ## Cosa cattura
104
110
 
@@ -172,6 +178,21 @@ Abbassala esplicitamente (es. `config.capture_rails_logs_min_level = :info`) sol
172
178
  `info` del framework. I log dei background job ereditano il `trace_id = job_id`, così log ed errore
173
179
  dello stesso job si correlano.
174
180
 
181
+ Il broadcast rispetta anche **`excluded_exceptions`**: se la riga nomina una classe esclusa non viene
182
+ inoltrata. Serve perché un'eccezione tolta dal canale errori rientrava da quello dei log — Rails la
183
+ registra comunque con `logger.error`. Con le esclusioni di default questo copre già
184
+ `ActionController::RoutingError`, cioè le favicon mancanti e le scansioni dei bot, che in produzione
185
+ sono la voce di gran lunga più numerosa dello stream. Per il rumore ripetuto che **non** è
186
+ un'eccezione c'è `excluded_log_patterns`:
187
+
188
+ ```ruby
189
+ config.excluded_log_patterns = [ /SolidQueue-[\d.]+ Error in thread/, "Rendered layout" ]
190
+ ```
191
+
192
+ Le stringhe valgono come testo letterale (vengono escapate), i `Regexp` come pattern. Entrambe le
193
+ liste gatano **solo** il mirror di `Rails.logger`: un `CloseYourIt.log` scritto di proposito non viene
194
+ mai silenziato.
195
+
175
196
  #### Flush di fine-vita
176
197
 
177
198
  Il buffer log viene svuotato automaticamente allo **shutdown del processo**: `CloseYourIt.init` registra
@@ -180,6 +201,31 @@ buffer), così i log sotto-batch di rake/CLI non vanno persi all'uscita. `SIGTER
180
201
  termina il processo **senza** eseguire gli `at_exit`: imposta `trap_signals = true` per intercettarlo e
181
202
  convertirlo in un exit pulito, oppure chiama `CloseYourIt.shutdown` esplicitamente prima di terminare.
182
203
 
204
+ #### Server che forkano (Puma cluster, Sidekiq, Unicorn)
205
+
206
+ I server che precaricano l'app (`preload_app!`) inizializzano la gemma nel processo **master**, poi
207
+ forkano i worker. Il worker pool asincrono e il timer del buffer log sono **thread**, e i thread non
208
+ sopravvivono a un `fork`: nel figlio quelle risorse sarebbero ereditate ma inerti e gli eventi non
209
+ partirebbero mai. La gemma **rileva il cambio di PID da sé** (lazy, al primo evento del figlio) e ricrea
210
+ worker e buffer nel processo corrente — nella maggior parte dei casi non devi fare nulla.
211
+
212
+ Per ricrearle **subito** dopo il fork (invece di attendere il primo evento) chiama
213
+ `CloseYourIt.after_fork` nel worker hook del tuo server:
214
+
215
+ ```ruby
216
+ # Puma (config/puma.rb)
217
+ on_worker_boot { CloseYourIt.after_fork }
218
+
219
+ # Sidekiq (config/initializers/sidekiq.rb)
220
+ Sidekiq.configure_server { |config| config.on(:startup) { CloseYourIt.after_fork } }
221
+
222
+ # Unicorn (config/unicorn.rb)
223
+ after_fork { |server, worker| CloseYourIt.after_fork }
224
+ ```
225
+
226
+ `CloseYourIt.after_fork` abbandona le risorse ereditate **senza drenarle** (non tenta un join sui thread
227
+ del padre e non riflusha il buffer, che rispedirebbe eventi del padre) ed è sicuro da chiamare più volte.
228
+
183
229
  ### Contesto, breadcrumbs e messaggi (manuale)
184
230
 
185
231
  ```ruby
@@ -201,6 +247,21 @@ all'evento d'errore catturato nello stesso contesto di esecuzione.
201
247
  Il Railtie si iscrive a `sql.active_record`: ogni query oltre `slow_query_threshold_ms` (esclusi
202
248
  `SCHEMA`/`CACHE`) viene inviata come `slow_query` alla pipeline metriche. Lo SQL è **offuscato** (bind esclusi).
203
249
 
250
+ Sono escluse anche le query che combaciano con `excluded_query_patterns` — di default le tabelle di
251
+ servizio del Solid stack (`solid_queue_`, `solid_cache_`, `solid_cable_`). Sono infrastruttura del
252
+ framework: una loro query lenta non si corregge leggendo il proprio repo, dice solo che il database è
253
+ in contesa, e in compenso sommerge le query dell'applicazione (su un backend reale erano il **62%**
254
+ dei campioni). Per misurarle davvero azzera la lista:
255
+
256
+ ```ruby
257
+ config.excluded_query_patterns = [] # misura tutto
258
+ config.excluded_query_patterns += [ "pg_stat_activity" ] # o aggiungi le tue
259
+ ```
260
+
261
+ Il filtro vale solo per la **misura**: le breadcrumb «quali query prima del crash» e il profiling N+1
262
+ continuano a vedere tutte le query, perché lì una tabella di servizio letta molte volte è essa stessa
263
+ un sintomo.
264
+
204
265
  ### Metodi lenti → metriche
205
266
 
206
267
  ```ruby
@@ -257,6 +318,36 @@ tutte ritoccabili.
257
318
  > switch. (Pendant server-side dei verdetti `performance_issue` di closeyourit-js: qui i `subtype`
258
319
  > sono quelli del backend Rails — N+1, query count, request/HTTP esterne lente.)
259
320
 
321
+ ## Metriche dei background job
322
+
323
+ Oltre agli **errori** dei job (sezione precedente), la gemma misura **automaticamente** durata di
324
+ esecuzione e attesa in coda dei background job e, oltre le soglie, invia metriche `performance_issue`
325
+ sulla pipeline metriche (`/api/v1/projects/:id/metrics`). Funziona con **ActiveJob** (via notifiche
326
+ `ActiveSupport::Notifications`) e con **Sidekiq** (server middleware), senza strumentazione manuale.
327
+ Due `subtype`:
328
+
329
+ - **`slow_job`** — la durata del `perform` supera `slow_job_threshold_ms` (default 5000 ms).
330
+ - **`job_queue_latency`** — l'attesa in coda (dall'enqueue all'inizio dell'esecuzione) supera
331
+ `job_queue_latency_threshold_ms` (default 60000 ms): coda intasata o worker insufficienti.
332
+
333
+ Ogni metrica porta la **label** (nome della classe del job), la **queue**, l'**adapter**
334
+ (`active_job`/`sidekiq`), l'**attempt** (numero di esecuzione: rende visibili i **retry**), la
335
+ **release** e il **trace_id** (= `job_id` / `jid`, che correla la metrica a log ed errori dello stesso
336
+ job). **Gli argomenti del job non vengono mai inviati** (solo la label).
337
+
338
+ È **ON di default** (a differenza dei verdetti performance opt-in): la visibilità dei job lenti, in
339
+ ritardo o ripetutamente ritentati non deve richiedere configurazione in ogni app, e l'overhead è basso
340
+ (una notifica per job, non il profiling di ogni query). Il rumore è tenuto a bada dalle **soglie** (i
341
+ job normali restano sotto soglia → nessuna metrica) e dal **sampling** (`jobs_sample_rate`, applicato
342
+ solo ai job già oltre soglia). Per spegnerlo del tutto:
343
+
344
+ ```ruby
345
+ CloseYourIt.init do |c|
346
+ # …endpoint_url / token / project_id…
347
+ c.monitor_jobs = false
348
+ end
349
+ ```
350
+
260
351
  ## Privacy & PII
261
352
 
262
353
  Privacy-by-default (`send_pii = false`). In sintesi:
@@ -277,17 +368,45 @@ Privacy-by-default (`send_pii = false`). In sintesi:
277
368
  Il trasporto è fire-and-forget: non solleva mai e non blocca la request. Per non lasciare
278
369
  fallimenti silenziosi, ogni risposta HTTP non-2xx (es. `401` token errato, `404` progetto
279
370
  inesistente) viene loggata a `warn`, così come gli eventi scartati a coda piena. I contatori
280
- sono ispezionabili a runtime:
371
+ sono ispezionabili a runtime (`snapshot` è un alias di `to_h`):
281
372
 
282
373
  ```ruby
283
374
  CloseYourIt.stats.to_h
284
- # => { enqueued: 128, dropped: 0, sent: 126, failed: 2 }
375
+ # => { enqueued: 128, dropped: 1, sent: 125, failed: 2, timeout: 1 }
285
376
  ```
286
377
 
287
378
  - `enqueued` — eventi accettati per l'invio
288
- - `dropped` — scartati perché la coda async era piena (mai backpressure)
379
+ - `dropped` — scartati prima dell'invio: coda async piena, `before_send` → `nil`/eccezione, sampling
289
380
  - `sent` — risposta HTTP 2xx
290
381
  - `failed` — errore di rete o status non-2xx (vedi i log a `warn`)
382
+ - `timeout` — sotto-conteggio di `failed`: fallimenti per timeout di rete (connettività)
383
+
384
+ ### Hook `on_diagnostic` (opt-in)
385
+
386
+ Per osservare il ciclo di vita di ogni evento senza abilitare log rumorosi, registra un hook. È
387
+ invocato a ogni tappa — `:enqueue`, `:send`, `:drop`, `:timeout`, `:shutdown` — con dettagli privi
388
+ di dati sensibili:
389
+
390
+ ```ruby
391
+ CloseYourIt.init do |c|
392
+ # ...
393
+ c.on_diagnostic = ->(event, details) do
394
+ StatsD.increment("closeyourit.#{event}", tags: details) # es. inoltra a metriche interne
395
+ end
396
+ end
397
+ ```
398
+
399
+ - `:enqueue` — `{ path: … }` accodato per l'invio
400
+ - `:send` — `{ status: 202 }` consegnato (2xx)
401
+ - `:drop` — scartato: `{ reason: :queue_full | :before_send | :sampled | :error | :response | :network, … }`
402
+ - `:timeout` — `{ error: "Net::OpenTimeout" }` timeout di rete
403
+ - `:shutdown` — `{ stats: {…} }` snapshot alla terminazione del processo, emesso una sola volta per
404
+ sessione (best-effort: invii ancora in volo oltre il breve timeout di drain possono non esservi
405
+ riflessi — il drain non blocca l'uscita)
406
+
407
+ L'hook è **locale e non ricorsivo**: non invia mai telemetria, e una notifica emessa dal suo interno
408
+ è soppressa — non può innescare loop di auto-monitoraggio. Un hook che solleva è isolato (loggato,
409
+ mai propagato nell'app). Tienilo veloce: gira sincrono nel thread chiamante, come `before_send`.
291
410
 
292
411
  ## Sviluppo
293
412
 
@@ -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
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
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,17 @@ 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
+
113
151
  # Radice del progetto: base per il filename relativo dei frame (culprit cross-SDK). Lazy:
114
152
  # auto-rilevata da Rails.root o Dir.pwd al primo accesso se non impostata esplicitamente.
115
153
  @project_root = nil
@@ -123,6 +161,18 @@ module CloseYourIt
123
161
  @excluded_exceptions = Array(list).map { |item| item.is_a?(Regexp) ? item : item.to_s }
124
162
  end
125
163
 
164
+ # Pattern del broadcast Rails.logger da scartare. Le stringhe diventano Regexp (match letterale
165
+ # sul testo): chi scrive `config.excluded_log_patterns = ["Rendered layout"]` intende quello.
166
+ def excluded_log_patterns=(list)
167
+ @excluded_log_patterns = Array(list).map { |item| item.is_a?(Regexp) ? item : Regexp.new(Regexp.escape(item.to_s)) }
168
+ end
169
+
170
+ # Query da non misurare. Stessa normalizzazione di excluded_log_patterns: String = testo letterale
171
+ # (un nome di tabella si scrive così, non come pattern), Regexp = pattern.
172
+ def excluded_query_patterns=(list)
173
+ @excluded_query_patterns = Array(list).map { |item| item.is_a?(Regexp) ? item : Regexp.new(Regexp.escape(item.to_s)) }
174
+ end
175
+
126
176
  def filter_parameters=(list)
127
177
  @filter_parameters = Array(list)
128
178
  end
@@ -177,9 +227,15 @@ module CloseYourIt
177
227
  Dir.pwd
178
228
  end
179
229
 
180
- # Auto-rilevamento release dalle env di deploy/CI o dal git short SHA. Mai solleva.
230
+ # Un tag semver (con `v` opzionale) è preferito allo short SHA come release: converge con quello
231
+ # che registra la CI (che tagga), mentre lo short SHA crea release duplicate lato backend (CYRB-9).
232
+ SEMVER_TAG = /\Av?\d+\.\d+\.\d+([-+.].+)?\z/
233
+
234
+ # Auto-rilevamento release: prima un tag semver (APP_GIT_TAG/GIT_TAG), poi lo short SHA dalle env
235
+ # di deploy/CI o dal git. Mai solleva.
181
236
  def detect_release
182
- ENV["KAMAL_VERSION"] ||
237
+ detect_tag ||
238
+ ENV["KAMAL_VERSION"] ||
183
239
  ENV["GIT_SHA"] ||
184
240
  ENV["GIT_REVISION"] ||
185
241
  ENV["SOURCE_VERSION"] ||
@@ -202,6 +258,16 @@ module CloseYourIt
202
258
  nil
203
259
  end
204
260
 
261
+ # Tag semver da APP_GIT_TAG poi GIT_TAG: accetta solo un valore non-blank con forma semver
262
+ # (v opzionale + MAJOR.MINOR.PATCH + eventuale suffisso). Scarta `unknown`, vuoto, nomi di
263
+ # branch, ecc. → nil, così detect_release cade sulla catena SHA.
264
+ def detect_tag
265
+ tag = ENV["APP_GIT_TAG"] || ENV["GIT_TAG"]
266
+ return nil if blank?(tag)
267
+
268
+ SEMVER_TAG.match?(tag) ? tag : nil
269
+ end
270
+
205
271
  UUID_FORMAT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
206
272
 
207
273
  def insecure_endpoint?
@@ -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
@@ -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
@@ -10,6 +10,8 @@ require_relative "log_broadcast"
10
10
  require_relative "net_http_patch"
11
11
  require_relative "../subscribers/slow_query"
12
12
  require_relative "../subscribers/request_performance"
13
+ require_relative "../subscribers/job_performance"
14
+ require_relative "../sidekiq/job_metrics_middleware"
13
15
 
14
16
  module CloseYourIt
15
17
  module Rails
@@ -87,6 +89,24 @@ module CloseYourIt
87
89
  end
88
90
  end
89
91
 
92
+ # Misura durata e attesa in coda dei job ActiveJob via notifiche ActiveSupport: `perform_start`
93
+ # dà l'attesa (now - enqueued_at) appena il job parte, `perform` dà la durata dell'esecuzione a
94
+ # fine job. Oltre soglia → metriche slow_job / job_queue_latency. No-op se monitor_jobs è OFF.
95
+ initializer "closeyourit.subscribe_active_job_performance" do
96
+ jobs = CloseYourIt::Subscribers::JobPerformance.new
97
+
98
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |*args|
99
+ job = ActiveSupport::Notifications::Event.new(*args).payload[:job]
100
+ jobs.active_job_started(job) if job
101
+ end
102
+
103
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
104
+ event = ActiveSupport::Notifications::Event.new(*args)
105
+ job = event.payload[:job]
106
+ jobs.active_job_performed(job, event.duration) if job
107
+ end
108
+ end
109
+
90
110
  # Cattura gli errori HANDLED riportati via Rails.error.report (Rails 7+).
91
111
  initializer "closeyourit.error_reporter" do
92
112
  if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
@@ -110,11 +130,16 @@ module CloseYourIt
110
130
  end
111
131
  end
112
132
 
113
- # Cattura gli errori dei job Sidekiq (solo se Sidekiq è presente).
133
+ # Cattura gli errori dei job Sidekiq + misura durata/attesa via server middleware (solo se
134
+ # Sidekiq è presente). Il middleware è no-op effettivo se monitor_jobs è OFF (la decisione vive
135
+ # in JobPerformance#record).
114
136
  initializer "closeyourit.sidekiq" do
115
137
  if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
116
138
  ::Sidekiq.configure_server do |sidekiq_config|
117
139
  sidekiq_config.error_handlers << CloseYourIt::Sidekiq::ErrorHandler.new
140
+ sidekiq_config.server_middleware do |chain|
141
+ chain.add CloseYourIt::Sidekiq::JobMetricsMiddleware
142
+ end
118
143
  end
119
144
  end
120
145
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "rack/utils"
5
4
 
6
5
  module CloseYourIt
7
6
  module Rails
@@ -60,6 +59,10 @@ module CloseYourIt
60
59
  cookie = env["HTTP_COOKIE"]
61
60
  return nil if cookie.nil? || cookie.empty?
62
61
 
62
+ # `rack/utils` caricato lazy: il middleware gira solo dentro un'app Rack (dove Rack c'è di
63
+ # sicuro), così `require "closeyourit-ruby"` non forza Rack in app non-web (CLI/worker) e la
64
+ # gemma resta installabile senza dichiarare `rack` tra le dipendenze (CYRB-13).
65
+ require "rack/utils"
63
66
  Rack::Utils.parse_cookies_header(cookie)[REPLAY_COOKIE].to_s.then { |id| id.empty? ? nil : id }
64
67
  end
65
68
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../subscribers/job_performance"
4
+
5
+ module CloseYourIt
6
+ module Sidekiq
7
+ # Server middleware Sidekiq (registrato dal railtie solo se Sidekiq è presente) che misura la durata
8
+ # di esecuzione e l'attesa in coda del job, poi delega a Subscribers::JobPerformance l'emissione
9
+ # delle metriche oltre soglia. Non altera il job: cronometra attorno allo `yield` e ri-solleva
10
+ # qualunque errore invariato (la cattura degli errori è dell'ErrorHandler). La misurazione avviene
11
+ # nell'`ensure`, così è presa anche per i job che sollevano; la telemetria è isolata (un errore nel
12
+ # nostro codice non disturba mai il job ospite). No-op effettivo se `monitor_jobs` è OFF (la
13
+ # decisione vive in #record).
14
+ class JobMetricsMiddleware
15
+ def initialize(subscriber = nil)
16
+ @subscriber = subscriber
17
+ end
18
+
19
+ def call(_worker, job, queue)
20
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
+ # L'attesa in coda si conosce all'INIZIO (now - enqueued_at); Sidekiq mette enqueued_at come
22
+ # epoch in secondi. Calcolata prima dello yield per non includere la durata del job.
23
+ latency = Subscribers::JobPerformance.latency_ms(job["enqueued_at"], now: Time.now.utc)
24
+ yield
25
+ ensure
26
+ emit(job, queue, started, latency)
27
+ end
28
+
29
+ private
30
+
31
+ def emit(job, queue, started, latency)
32
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
33
+ subscriber.record(
34
+ job_class: job["wrapped"] || job["class"],
35
+ queue: queue || job["queue"],
36
+ adapter: "sidekiq",
37
+ duration_ms: duration_ms,
38
+ queue_latency_ms: latency,
39
+ attempt: attempt(job),
40
+ trace_id: job["jid"]
41
+ )
42
+ rescue StandardError => e
43
+ CloseYourIt.internal_logger.error("CloseYourIt job metrics: #{e.class}: #{e.message}")
44
+ end
45
+
46
+ def subscriber
47
+ @subscriber ||= Subscribers::JobPerformance.new
48
+ end
49
+
50
+ # Numero di esecuzione 1-based. Sidekiq NON imposta `retry_count` al primo run (nil), lo porta a 0
51
+ # al primo retry, 1 al secondo, ... → attempt = retry_count + 2 quando presente, 1 al primo run.
52
+ def attempt(job)
53
+ count = job["retry_count"]
54
+ count.nil? ? 1 : count + 2
55
+ end
56
+ end
57
+ end
58
+ end
@@ -4,12 +4,14 @@ require "concurrent"
4
4
 
5
5
  module CloseYourIt
6
6
  # Contatori diagnostici thread-safe del client: quanti eventi sono stati accodati,
7
- # scartati (coda piena), spediti con successo o falliti (rete o status non-2xx).
8
- # Servono a rendere visibili i fallimenti silenziosi del trasporto fire-and-forget.
7
+ # scartati (coda piena / before_send / sampling), spediti con successo, falliti (rete o status
8
+ # non-2xx) e, tra i falliti, quanti per timeout di rete. Rendono visibili i fallimenti silenziosi
9
+ # del trasporto fire-and-forget. `timeout` è un sotto-conteggio di `failed` (un timeout resta un
10
+ # fallimento d'invio): li teniamo distinti per isolare i problemi di connettività dai non-2xx.
9
11
  #
10
- # CloseYourIt.stats.to_h # => { enqueued: 12, dropped: 0, sent: 11, failed: 1 }
12
+ # CloseYourIt.stats.to_h # => { enqueued: 12, dropped: 0, sent: 11, failed: 1, timeout: 1 }
11
13
  class Stats
12
- COUNTERS = %i[enqueued dropped sent failed].freeze
14
+ COUNTERS = %i[enqueued dropped sent failed timeout].freeze
13
15
 
14
16
  def initialize
15
17
  @counters = COUNTERS.to_h { |name| [ name, Concurrent::AtomicFixnum.new(0) ] }
@@ -29,6 +31,11 @@ module CloseYourIt
29
31
  @counters.transform_values(&:value)
30
32
  end
31
33
 
34
+ # Fotografia thread-safe dei contatori (ogni valore letto atomicamente). È lo stesso Hash di
35
+ # `to_h`, con un nome esplicito per il caso d'uso "leggo la diagnostica locale" (CYRB-12): pura
36
+ # lettura in-memory, non invia mai telemetria.
37
+ alias_method :snapshot, :to_h
38
+
32
39
  def reset!
33
40
  @counters.each_value { |counter| counter.value = 0 }
34
41
  self
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../events/job_metric_event"
5
+
6
+ module CloseYourIt
7
+ module Subscribers
8
+ # Misura durata di esecuzione e attesa in coda (queue latency) dei background job — ActiveJob e
9
+ # Sidekiq — e, oltre le soglie configurate, emette metriche performance_issue (subtype `slow_job`
10
+ # e `job_queue_latency`). Logica PURA e SENZA STATO condiviso: tutto arriva per parametri, quindi
11
+ # job concorrenti sullo stesso thread/processo non si contaminano. Il wiring ad
12
+ # ActiveSupport::Notifications e al middleware Sidekiq vive altrove (Railtie / JobMetricsMiddleware).
13
+ # Rispetta il master switch `monitor_jobs`, le soglie e il `jobs_sample_rate`.
14
+ class JobPerformance
15
+ def initialize(configuration = nil)
16
+ @configuration = configuration
17
+ end
18
+
19
+ # Punto unico di decisione: dai valori misurati (durata e/o attesa) costruisce 0..2 metriche,
20
+ # applica le soglie (stretto `>`, così X non genera rumore e X+1 sì) e il sampling, poi le spedisce
21
+ # fire-and-forget. `duration_ms` e `queue_latency_ms` sono opzionali: ActiveJob li fornisce da due
22
+ # hook distinti (perform_start → attesa, perform → durata), Sidekiq entrambi in una sola chiamata.
23
+ # Il sampling è applicato SOLO ai candidati già oltre soglia (i job normali non consumano né
24
+ # generano nulla).
25
+ def record(job_class:, queue: nil, adapter: nil, duration_ms: nil, queue_latency_ms: nil,
26
+ attempt: nil, trace_id: nil)
27
+ config = configuration
28
+ return unless config.monitor_jobs
29
+
30
+ common = { job_class: job_class, queue: queue, adapter: adapter, attempt: attempt, trace_id: trace_id }
31
+ events = []
32
+ events << build(config, "slow_job", duration_ms, common) if slow?(config, duration_ms)
33
+ events << build(config, "job_queue_latency", queue_latency_ms, common) if waited?(config, queue_latency_ms)
34
+ return if events.empty?
35
+ return unless sampled?(config)
36
+
37
+ events.each { |event| CloseYourIt.capture_event(event) }
38
+ nil
39
+ end
40
+
41
+ # Hook `perform_start.active_job`: l'attesa in coda è nota appena il job parte (now - enqueued_at).
42
+ def active_job_started(job, now: Time.now.utc)
43
+ record(
44
+ job_class: job.class.name,
45
+ queue: (job.queue_name if job.respond_to?(:queue_name)),
46
+ adapter: "active_job",
47
+ queue_latency_ms: self.class.latency_ms(enqueued_at(job), now: now),
48
+ attempt: (job.executions if job.respond_to?(:executions)),
49
+ trace_id: (job.job_id if job.respond_to?(:job_id))
50
+ )
51
+ end
52
+
53
+ # Hook `perform.active_job`: a fine esecuzione la durata è `event.duration` (ms).
54
+ def active_job_performed(job, duration_ms)
55
+ record(
56
+ job_class: job.class.name,
57
+ queue: (job.queue_name if job.respond_to?(:queue_name)),
58
+ adapter: "active_job",
59
+ duration_ms: duration_ms,
60
+ attempt: (job.executions if job.respond_to?(:executions)),
61
+ trace_id: (job.job_id if job.respond_to?(:job_id))
62
+ )
63
+ end
64
+
65
+ # Normalizza `enqueued_at` (Time, epoch Numerico in secondi come Sidekiq, o String ISO8601) in
66
+ # attesa (ms) rispetto a `now`. nil o non parsabile → nil: nessuna metrica di attesa (adapter che
67
+ # non popola l'istante di enqueue). Clamp a 0 se negativa (clock skew, enqueue "nel futuro"): lo
68
+ # schema di ingest esige `duration_ms >= 0`.
69
+ def self.latency_ms(enqueued_at, now:)
70
+ started = to_time(enqueued_at)
71
+ return nil if started.nil?
72
+
73
+ ms = (now - started) * 1000.0
74
+ ms.negative? ? 0.0 : ms
75
+ end
76
+
77
+ def self.to_time(value)
78
+ case value
79
+ when Time then value
80
+ when Numeric then Time.at(value)
81
+ when String then parse_time(value)
82
+ end
83
+ end
84
+
85
+ def self.parse_time(value)
86
+ Time.parse(value)
87
+ rescue ArgumentError
88
+ nil
89
+ end
90
+
91
+ private
92
+
93
+ def enqueued_at(job)
94
+ job.enqueued_at if job.respond_to?(:enqueued_at)
95
+ end
96
+
97
+ def configuration
98
+ @configuration || CloseYourIt.configuration
99
+ end
100
+
101
+ def slow?(config, duration_ms)
102
+ duration_ms && config.slow_job_threshold_ms && duration_ms > config.slow_job_threshold_ms
103
+ end
104
+
105
+ def waited?(config, latency_ms)
106
+ latency_ms && config.job_queue_latency_threshold_ms &&
107
+ latency_ms > config.job_queue_latency_threshold_ms
108
+ end
109
+
110
+ def sampled?(config)
111
+ rate = config.jobs_sample_rate.to_f
112
+ return true if rate >= 1.0
113
+ return false if rate <= 0.0
114
+
115
+ Random.rand < rate
116
+ end
117
+
118
+ def build(config, subtype, duration_ms, common)
119
+ JobMetricEvent.new(common.merge(subtype: subtype, duration_ms: duration_ms), config)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -21,6 +21,7 @@ module CloseYourIt
21
21
  config = @configuration || CloseYourIt.configuration
22
22
  return if ignored_name?(name)
23
23
  return if duration_ms < config.slow_query_threshold_ms
24
+ return if excluded_sql?(config, sql)
24
25
 
25
26
  event = SlowQueryEvent.new(
26
27
  { name: name, sql: sql, cached: cached, connection: connection,
@@ -77,6 +78,26 @@ module CloseYourIt
77
78
  def ignored_name?(name)
78
79
  name.nil? || IGNORED_NAMES.include?(name)
79
80
  end
81
+
82
+ # Query esclusa dalla MISURA dei rallentamenti (config.excluded_query_patterns). Il filtro sta
83
+ # solo qui, non in #breadcrumb né in #profile:
84
+ #
85
+ # - breadcrumb: la cronologia "quali query prima del crash" ha valore diagnostico anche quando
86
+ # la query è del framework — nasconderla renderebbe la sequenza incompleta e bugiarda;
87
+ # - profile: è per-richiesta e serve la detection N+1, dove una tabella di servizio letta molte
88
+ # volte è essa stessa un sintomo da vedere.
89
+ #
90
+ # Un rallentamento va misurato se qualcuno può intervenire; una breadcrumb va tenuta se aiuta a
91
+ # capire. Sono due domande diverse, e questa lista risponde solo alla prima.
92
+ def excluded_sql?(config, sql)
93
+ return false if sql.nil?
94
+
95
+ patterns = config.excluded_query_patterns
96
+ return false if patterns.empty?
97
+
98
+ text = sql.to_s
99
+ patterns.any? { |pattern| pattern.match?(text) }
100
+ end
80
101
  end
81
102
  end
82
103
  end
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ require "timeout"
6
7
 
7
8
  module CloseYourIt
8
9
  # Spedisce un payload a un path di ingest (errori → /events, metriche → /metrics) via HTTP POST
@@ -14,6 +15,10 @@ module CloseYourIt
14
15
  # Ri-POSTiamo a Location preservando metodo + body, così l'evento non si perde in silenzio.
15
16
  MAX_REDIRECTS = 2
16
17
 
18
+ # Un timeout di rete (apertura o lettura) è un fallimento d'invio speciale: lo isoliamo dai non-2xx
19
+ # e dagli altri errori di rete perché segnala tipicamente problemi di connettività (CYRB-12).
20
+ TIMEOUT_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, Timeout::Error ].freeze
21
+
17
22
  def initialize(configuration)
18
23
  @configuration = configuration
19
24
  end
@@ -22,19 +27,31 @@ module CloseYourIt
22
27
  response = post(payload, path)
23
28
  if response.is_a?(Net::HTTPSuccess)
24
29
  CloseYourIt.stats.increment(:sent)
30
+ CloseYourIt.notify_diagnostic(:send, status: response.code.to_i)
25
31
  else
26
32
  CloseYourIt.stats.increment(:failed)
27
33
  CloseYourIt.internal_logger.warn("CloseYourIt transport: HTTP #{response.code}#{error_detail(response)} su #{path}")
34
+ CloseYourIt.notify_diagnostic(:drop, reason: :response, status: response.code.to_i)
28
35
  end
29
36
  response
30
37
  rescue StandardError => e
31
38
  CloseYourIt.stats.increment(:failed)
32
39
  CloseYourIt.internal_logger.error("CloseYourIt transport: #{e.class}: #{e.message}")
40
+ if timeout_error?(e)
41
+ CloseYourIt.stats.increment(:timeout)
42
+ CloseYourIt.notify_diagnostic(:timeout, error: e.class.name)
43
+ else
44
+ CloseYourIt.notify_diagnostic(:drop, reason: :network, error: e.class.name)
45
+ end
33
46
  nil
34
47
  end
35
48
 
36
49
  private
37
50
 
51
+ def timeout_error?(error)
52
+ TIMEOUT_ERRORS.any? { |klass| error.is_a?(klass) }
53
+ end
54
+
38
55
  def post(payload, path)
39
56
  body = JSON.generate(payload)
40
57
  origin = URI.parse("#{base_url}#{path}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -17,12 +17,14 @@ 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
19
  require_relative "closeyourit/events/performance_issue_event"
20
+ require_relative "closeyourit/events/job_metric_event"
20
21
  require_relative "closeyourit/performance/request_profile"
21
22
  require_relative "closeyourit/performance/rollup"
22
23
  require_relative "closeyourit/log_device"
23
24
  require_relative "closeyourit/log_buffer"
24
25
  require_relative "closeyourit/subscribers/slow_query"
25
26
  require_relative "closeyourit/subscribers/request_performance"
27
+ require_relative "closeyourit/subscribers/job_performance"
26
28
  require_relative "closeyourit/instrumenter"
27
29
  require_relative "closeyourit/monitor"
28
30
  require_relative "closeyourit/client"
@@ -34,6 +36,7 @@ require_relative "closeyourit/rails/net_http_patch"
34
36
  require_relative "closeyourit/rails/active_job_extension"
35
37
  require_relative "closeyourit/rails/error_subscriber"
36
38
  require_relative "closeyourit/sidekiq/error_handler"
39
+ require_relative "closeyourit/sidekiq/job_metrics_middleware"
37
40
 
38
41
  # CloseYourIt — client di telemetria (errori + statistiche di query/metodi lenti)
39
42
  # che invia gli eventi all'endpoint di ingest di CloseYourIt.
@@ -46,12 +49,27 @@ module CloseYourIt
46
49
 
47
50
  CAPTURED_FLAG = :@__closeyourit_captured
48
51
 
52
+ # Flag thread-local che segna "sono già dentro l'hook diagnostico": impedisce che una notifica
53
+ # emessa DENTRO l'hook (o da codice da esso invocato) rientri e riesegua l'hook → niente loop di
54
+ # auto-monitoraggio (CYRB-12). È per-thread perché le tappe girano su thread diversi (worker pool
55
+ # per send/timeout, thread chiamante per enqueue/drop).
56
+ DIAGNOSTIC_GUARD = :__closeyourit_in_diagnostic
57
+
49
58
  class << self
50
59
  # Configura il client. Senza token/endpoint → no-op.
60
+ #
61
+ # Una re-init SPEGNE prima il client e il log buffer della configurazione precedente (CYRB-10):
62
+ # azzerarli e basta lascerebbe orfani il thread del worker pool e il TimerTask del buffer, e gli
63
+ # eventi ancora in coda andrebbero persi o flushati fuori tempo dal timer orfano con la vecchia
64
+ # credenziale. Riusa la semantica di fine-vita di #shutdown (flush del buffer → drain del worker
65
+ # con timeout, CYRB-5) — così la coda precedente è svuotata in modo prevedibile con la sua config.
66
+ # Idempotente: alla prima init (o senza eventi catturati) @client/@log_buffer sono nil → no-op.
51
67
  def init
68
+ shutdown
52
69
  @configuration = Configuration.new
53
70
  @client = nil
54
71
  @log_buffer = nil
72
+ @shutdown_notified = false # nuova sessione: :shutdown potrà essere notificato di nuovo
55
73
  yield(@configuration) if block_given?
56
74
  @configuration.validate!
57
75
  register_shutdown_flush
@@ -73,12 +91,13 @@ module CloseYourIt
73
91
  # Cattura un'eccezione e la spedisce (fire-and-forget). No-op se disabilitato,
74
92
  # se l'eccezione è esclusa o già catturata.
75
93
  def capture_exception(exception, handled: false, level: "error", contexts: nil)
94
+ return nil if in_diagnostic?
76
95
  return nil unless enabled?
77
96
  return nil if ignored_exception?(exception)
78
97
  return nil if exception_captured?(exception)
79
98
 
80
99
  mark_captured(exception)
81
- return nil unless sampled?
100
+ return record_drop(:sampled) unless sampled?
82
101
 
83
102
  event = ErrorEvent.from_exception(
84
103
  exception, configuration: configuration, handled: handled, level: level, contexts: contexts
@@ -88,6 +107,7 @@ module CloseYourIt
88
107
 
89
108
  # Spedisce un evento già costruito (slow_query/slow_method).
90
109
  def capture_event(event)
110
+ return nil if in_diagnostic?
91
111
  return nil unless enabled?
92
112
 
93
113
  client.capture_event(event)
@@ -96,8 +116,9 @@ module CloseYourIt
96
116
  # Invia un messaggio diagnostico esplicito (non un'eccezione). Soggetto a sampling + scope.
97
117
  # CloseYourIt.capture_message("cache miss storm", level: "warning")
98
118
  def capture_message(message, level: "info")
119
+ return nil if in_diagnostic?
99
120
  return nil unless enabled?
100
- return nil unless sampled?
121
+ return record_drop(:sampled) unless sampled?
101
122
 
102
123
  event = MessageEvent.new(message, level: level, configuration: configuration)
103
124
  client.capture_event(event)
@@ -181,9 +202,10 @@ module CloseYourIt
181
202
  # sorgente) e per impostare la sorgente solo via `.named` (child logger, parità dart/js — CYRB-8).
182
203
  # Le app usano `CloseYourIt.log` / `CloseYourIt.logger`.
183
204
  def emit_log(level, message, source: nil, attributes: {})
205
+ return nil if in_diagnostic?
184
206
  return nil unless logs_enabled?
185
207
  return nil if log_below_min_level?(level)
186
- return nil unless logs_sampled?
208
+ return record_drop(:sampled) unless logs_sampled?
187
209
 
188
210
  event = LogEvent.new(message, level: level, attributes: attributes,
189
211
  logger: source, configuration: configuration)
@@ -197,6 +219,37 @@ module CloseYourIt
197
219
  logs_enabled?
198
220
  end
199
221
 
222
+ # Vero se una riga del broadcast Rails.logger va scartata: nomina un'eccezione già presente in
223
+ # `excluded_exceptions`, oppure combacia con `excluded_log_patterns`.
224
+ #
225
+ # Serve perché un'eccezione esclusa dal canale ERRORI rientrava da quello dei LOG: Rails la
226
+ # registra con `logger.error`, il broadcast inoltrava la riga senza guardarla, e il rumore che
227
+ # `excluded_exceptions` aveva appena scartato ricompariva come log-entry. Il 2026-07-30 erano
228
+ # 48.000 voci su 49.985 nello stream, quasi tutte `ActionController::RoutingError` da favicon
229
+ # mancanti e scansioni di bot — che è nella lista di default dalla prima riga (CYRB-17).
230
+ #
231
+ # `ignored_exception?` non è applicabile: qui la classe arriva come TESTO dentro il messaggio, non
232
+ # come oggetto con `ancestors` da confrontare. Da cui il match per sottostringa sui matcher String.
233
+ #
234
+ # Vale SOLO per il mirror automatico di Rails.logger: un `CloseYourIt.log` scritto di proposito
235
+ # dallo sviluppatore non si silenzia mai (chi lo scrive ha già deciso che vuole quella riga).
236
+ def ignored_log_message?(text)
237
+ text = text.to_s
238
+ return false if text.empty?
239
+
240
+ config = configuration
241
+ named = config.excluded_exceptions.any? do |matcher|
242
+ if matcher.is_a?(Regexp)
243
+ matcher.match?(text)
244
+ else
245
+ # Un matcher vuoto combacerebbe con qualunque riga: mai silenziare tutto per una lista sporca.
246
+ !matcher.empty? && text.include?(matcher)
247
+ end
248
+ end
249
+
250
+ named || config.excluded_log_patterns.any? { |pattern| pattern.match?(text) }
251
+ end
252
+
200
253
  # Forza l'invio dei log bufferizzati (chiamato anche allo shutdown del processo).
201
254
  def flush_logs
202
255
  @log_buffer&.flush
@@ -213,25 +266,112 @@ module CloseYourIt
213
266
  # Ordine critico: prima il buffer (accoda l'ultimo batch nel worker), poi il worker (lo drena).
214
267
  @log_buffer&.shutdown
215
268
  @client&.shutdown
269
+ # Riepilogo di fine-vita: l'app riceve lo snapshot dei contatori senza log rumorosi. Emesso una
270
+ # sola volta per sessione (uno shutdown esplicito seguito dall'at_exit non deve duplicarlo; il
271
+ # flag è azzerato a ogni init). Lo snapshot è best-effort: eventuali invii ancora in volo oltre il
272
+ # breve timeout di drain possono non esservi riflessi — il drain non blocca l'uscita (CYRB-5).
273
+ unless @shutdown_notified
274
+ @shutdown_notified = true
275
+ notify_diagnostic(:shutdown, stats: stats.to_h)
276
+ end
277
+ nil
278
+ end
279
+
280
+ # Ripristina le risorse di invio in un processo figlio dopo un fork. Chiamalo dai worker hook dei
281
+ # server che forkano (Puma `on_worker_boot`, Sidekiq/Unicorn `after_fork`) per ricreare SUBITO worker
282
+ # pool e log buffer nel figlio, invece di attendere la rilevazione lazy al primo evento. Opzionale:
283
+ # la gemma rileva comunque il cambio PID da sé (vedi #ensure_current_process!). Idempotente e sicuro
284
+ # anche se client/buffer non sono ancora stati materializzati (→ no-op, verranno creati lazy).
285
+ def after_fork
286
+ discard_inherited_client!
287
+ @pid = Process.pid
216
288
  nil
217
289
  end
218
290
 
219
- # Contatori diagnostici del client (accodati/scartati/spediti/falliti).
220
- # CloseYourIt.stats.to_h # => { enqueued: …, dropped: …, sent: …, failed: … }
291
+ # Contatori diagnostici del client (accodati/scartati/spediti/falliti/timeout).
292
+ # CloseYourIt.stats.to_h # => { enqueued: …, dropped: …, sent: …, failed: …, timeout: … }
221
293
  def stats
222
294
  @stats ||= Stats.new
223
295
  end
224
296
 
297
+ # Notifica una tappa del ciclo di vita di un evento all'hook `on_diagnostic` (se configurato).
298
+ # `event` è uno tra :enqueue, :send, :drop, :timeout, :shutdown; `details` un Hash privo di dati
299
+ # sensibili (es. `{ reason: :queue_full }`, `{ status: 429 }`). Chiamato da Client/Transport/
300
+ # BackgroundWorker/LogBuffer. Garanzie (CYRB-12):
301
+ # * NON invia telemetria: tocca solo l'hook dell'app e i contatori in-memory;
302
+ # * NON innesca loop di auto-monitoraggio: durante l'hook il guard è alzato, e finché è alzato
303
+ # sono soppressi SIA i `notify_diagnostic` annidati SIA le API di telemetria (`capture_*`/log,
304
+ # vedi #in_diagnostic?). Poiché l'accodamento della telemetria è sincrono nel thread dell'hook
305
+ # (solo l'invio HTTP è async), sopprimere l'accodamento chiude il loop anche cross-thread;
306
+ # * NON solleva: un hook difettoso è isolato (logga su internal_logger) e mai propagato nell'app.
307
+ # L'hook osserva sempre la configurazione CORRENTE: una notifica in volo che completa dopo una
308
+ # re-init raggiunge l'hook nuovo (best-effort, coerente col modello fire-and-forget).
309
+ def notify_diagnostic(event, **details)
310
+ hook = configuration.on_diagnostic
311
+ return nil if hook.nil?
312
+ return nil if Thread.current[DIAGNOSTIC_GUARD]
313
+
314
+ Thread.current[DIAGNOSTIC_GUARD] = true
315
+ begin
316
+ hook.call(event, details)
317
+ rescue StandardError => e
318
+ internal_logger.error("CloseYourIt diagnostic hook: #{e.class}: #{e.message}")
319
+ ensure
320
+ Thread.current[DIAGNOSTIC_GUARD] = false
321
+ end
322
+ nil
323
+ end
324
+
225
325
  private
226
326
 
327
+ # Vero se il thread corrente sta eseguendo l'hook diagnostico: le API di telemetria diventano no-op
328
+ # per impedire che un hook (mal scritto) generi altra telemetria e inneschi auto-monitoraggio.
329
+ def in_diagnostic?
330
+ Thread.current[DIAGNOSTIC_GUARD] == true
331
+ end
332
+
333
+ # Registra uno scarto: incrementa il contatore aggregato `dropped` e notifica `:drop` con il motivo
334
+ # (queue_full/before_send/sampled/error). Un unico punto tiene allineati snapshot e hook.
335
+ def record_drop(reason, **details)
336
+ stats.increment(:dropped)
337
+ notify_diagnostic(:drop, reason: reason, **details)
338
+ nil
339
+ end
340
+
227
341
  def client
342
+ ensure_current_process!
228
343
  @client ||= Client.new(configuration)
229
344
  end
230
345
 
231
346
  def log_buffer
347
+ ensure_current_process!
232
348
  @log_buffer ||= LogBuffer.new(client: client, configuration: configuration)
233
349
  end
234
350
 
351
+ # Rileva un fork confrontando il PID del processo in cui @client/@log_buffer sono stati materializzati
352
+ # con quello corrente. In un figlio forkato i due oggetti sono ereditati dal padre, ma i loro thread
353
+ # — il worker pool di Client e il TimerTask di LogBuffer — vivono solo nel padre (il fork copia il
354
+ # solo thread chiamante): gli eventi accodati non partirebbero mai, restando affidati a thread che
355
+ # esistono soltanto nel padre. Al cambio di PID abbandoniamo i riferimenti ereditati SENZA #shutdown
356
+ # (nessun join possibile sui thread del padre, e un flush del buffer rispedirebbe eventi del padre) →
357
+ # i getter li ricreano lazy sotto il processo corrente. Nel padre lo stato resta invariato.
358
+ def ensure_current_process!
359
+ pid = Process.pid
360
+ return if @pid == pid
361
+
362
+ discard_inherited_client! if @pid # non alla prima materializzazione (@pid nil): nulla da abbandonare
363
+ @pid = pid
364
+ end
365
+
366
+ # Sgancia i riferimenti a client e log buffer senza drenarli. Usato sia dalla rilevazione lazy del
367
+ # fork sia da #after_fork: nel figlio i thread sottostanti non esistono, quindi non c'è nulla da
368
+ # joinare e non si deve flushare (rispedirebbe gli eventi del padre). Il GC raccoglie i vecchi
369
+ # oggetti; i getter ne creano di nuovi al prossimo accesso.
370
+ def discard_inherited_client!
371
+ @client = nil
372
+ @log_buffer = nil
373
+ end
374
+
235
375
  # I log seguono il master switch del client + il proprio flag dedicato.
236
376
  def logs_enabled?
237
377
  enabled? && configuration.logs_enabled
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessio Bussolari
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: logger
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.6'
26
40
  description: Gemma client che cattura eccezioni e le statistiche di query e metodi
27
41
  lenti e le invia, fire-and-forget, all'endpoint di ingest di CloseYourIt.
28
42
  email:
@@ -41,6 +55,7 @@ files:
41
55
  - lib/closeyourit/configuration.rb
42
56
  - lib/closeyourit/event.rb
43
57
  - lib/closeyourit/events/error_event.rb
58
+ - lib/closeyourit/events/job_metric_event.rb
44
59
  - lib/closeyourit/events/log_event.rb
45
60
  - lib/closeyourit/events/message_event.rb
46
61
  - lib/closeyourit/events/performance_issue_event.rb
@@ -65,7 +80,9 @@ files:
65
80
  - lib/closeyourit/scope.rb
66
81
  - lib/closeyourit/scrubber.rb
67
82
  - lib/closeyourit/sidekiq/error_handler.rb
83
+ - lib/closeyourit/sidekiq/job_metrics_middleware.rb
68
84
  - lib/closeyourit/stats.rb
85
+ - lib/closeyourit/subscribers/job_performance.rb
69
86
  - lib/closeyourit/subscribers/request_performance.rb
70
87
  - lib/closeyourit/subscribers/slow_query.rb
71
88
  - lib/closeyourit/transport.rb