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 +4 -4
- data/README.md +122 -3
- data/lib/closeyourit/background_worker.rb +1 -0
- data/lib/closeyourit/client.rb +26 -4
- data/lib/closeyourit/configuration.rb +71 -5
- data/lib/closeyourit/events/job_metric_event.rb +44 -0
- data/lib/closeyourit/log_buffer.rb +6 -0
- data/lib/closeyourit/rails/log_broadcast.rb +9 -2
- data/lib/closeyourit/rails/railtie.rb +26 -1
- data/lib/closeyourit/rails/request_context.rb +4 -1
- data/lib/closeyourit/sidekiq/job_metrics_middleware.rb +58 -0
- data/lib/closeyourit/stats.rb +11 -4
- data/lib/closeyourit/subscribers/job_performance.rb +123 -0
- data/lib/closeyourit/subscribers/slow_query.rb +21 -0
- data/lib/closeyourit/transport.rb +17 -0
- data/lib/closeyourit/version.rb +1 -1
- data/lib/closeyourit-ruby.rb +145 -5
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '083ced8226884d6ceed86ae4da2a17cb7f9bfdfa5f3cd6073ac09268c4545d6e'
|
|
4
|
+
data.tar.gz: 75b18abef8b8c45ae14647c4bc102276286a2c71c1056d37274d0fe350f20555
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
|
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
|
|
data/lib/closeyourit/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, :
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/closeyourit/stats.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
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}")
|
data/lib/closeyourit/version.rb
CHANGED
data/lib/closeyourit-ruby.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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
|