closeyourit-ruby 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ require "logger"
5
5
  require_relative "closeyourit/version"
6
6
  require_relative "closeyourit/configuration"
7
7
  require_relative "closeyourit/breadcrumb"
8
+ require_relative "closeyourit/trace_context"
8
9
  require_relative "closeyourit/scope"
9
10
  require_relative "closeyourit/scrubber"
10
11
  require_relative "closeyourit/stats"
@@ -17,12 +18,14 @@ require_relative "closeyourit/events/slow_query_event"
17
18
  require_relative "closeyourit/events/slow_method_event"
18
19
  require_relative "closeyourit/events/log_event"
19
20
  require_relative "closeyourit/events/performance_issue_event"
21
+ require_relative "closeyourit/events/job_metric_event"
20
22
  require_relative "closeyourit/performance/request_profile"
21
23
  require_relative "closeyourit/performance/rollup"
22
24
  require_relative "closeyourit/log_device"
23
25
  require_relative "closeyourit/log_buffer"
24
26
  require_relative "closeyourit/subscribers/slow_query"
25
27
  require_relative "closeyourit/subscribers/request_performance"
28
+ require_relative "closeyourit/subscribers/job_performance"
26
29
  require_relative "closeyourit/instrumenter"
27
30
  require_relative "closeyourit/monitor"
28
31
  require_relative "closeyourit/client"
@@ -34,6 +37,7 @@ require_relative "closeyourit/rails/net_http_patch"
34
37
  require_relative "closeyourit/rails/active_job_extension"
35
38
  require_relative "closeyourit/rails/error_subscriber"
36
39
  require_relative "closeyourit/sidekiq/error_handler"
40
+ require_relative "closeyourit/sidekiq/job_metrics_middleware"
37
41
 
38
42
  # CloseYourIt — client di telemetria (errori + statistiche di query/metodi lenti)
39
43
  # che invia gli eventi all'endpoint di ingest di CloseYourIt.
@@ -46,12 +50,27 @@ module CloseYourIt
46
50
 
47
51
  CAPTURED_FLAG = :@__closeyourit_captured
48
52
 
53
+ # Flag thread-local che segna "sono già dentro l'hook diagnostico": impedisce che una notifica
54
+ # emessa DENTRO l'hook (o da codice da esso invocato) rientri e riesegua l'hook → niente loop di
55
+ # auto-monitoraggio (CYRB-12). È per-thread perché le tappe girano su thread diversi (worker pool
56
+ # per send/timeout, thread chiamante per enqueue/drop).
57
+ DIAGNOSTIC_GUARD = :__closeyourit_in_diagnostic
58
+
49
59
  class << self
50
60
  # Configura il client. Senza token/endpoint → no-op.
61
+ #
62
+ # Una re-init SPEGNE prima il client e il log buffer della configurazione precedente (CYRB-10):
63
+ # azzerarli e basta lascerebbe orfani il thread del worker pool e il TimerTask del buffer, e gli
64
+ # eventi ancora in coda andrebbero persi o flushati fuori tempo dal timer orfano con la vecchia
65
+ # credenziale. Riusa la semantica di fine-vita di #shutdown (flush del buffer → drain del worker
66
+ # con timeout, CYRB-5) — così la coda precedente è svuotata in modo prevedibile con la sua config.
67
+ # Idempotente: alla prima init (o senza eventi catturati) @client/@log_buffer sono nil → no-op.
51
68
  def init
69
+ shutdown
52
70
  @configuration = Configuration.new
53
71
  @client = nil
54
72
  @log_buffer = nil
73
+ @shutdown_notified = false # nuova sessione: :shutdown potrà essere notificato di nuovo
55
74
  yield(@configuration) if block_given?
56
75
  @configuration.validate!
57
76
  register_shutdown_flush
@@ -73,12 +92,13 @@ module CloseYourIt
73
92
  # Cattura un'eccezione e la spedisce (fire-and-forget). No-op se disabilitato,
74
93
  # se l'eccezione è esclusa o già catturata.
75
94
  def capture_exception(exception, handled: false, level: "error", contexts: nil)
95
+ return nil if in_diagnostic?
76
96
  return nil unless enabled?
77
97
  return nil if ignored_exception?(exception)
78
98
  return nil if exception_captured?(exception)
79
99
 
80
100
  mark_captured(exception)
81
- return nil unless sampled?
101
+ return record_drop(:sampled) unless sampled?
82
102
 
83
103
  event = ErrorEvent.from_exception(
84
104
  exception, configuration: configuration, handled: handled, level: level, contexts: contexts
@@ -88,6 +108,7 @@ module CloseYourIt
88
108
 
89
109
  # Spedisce un evento già costruito (slow_query/slow_method).
90
110
  def capture_event(event)
111
+ return nil if in_diagnostic?
91
112
  return nil unless enabled?
92
113
 
93
114
  client.capture_event(event)
@@ -96,8 +117,9 @@ module CloseYourIt
96
117
  # Invia un messaggio diagnostico esplicito (non un'eccezione). Soggetto a sampling + scope.
97
118
  # CloseYourIt.capture_message("cache miss storm", level: "warning")
98
119
  def capture_message(message, level: "info")
120
+ return nil if in_diagnostic?
99
121
  return nil unless enabled?
100
- return nil unless sampled?
122
+ return record_drop(:sampled) unless sampled?
101
123
 
102
124
  event = MessageEvent.new(message, level: level, configuration: configuration)
103
125
  client.capture_event(event)
@@ -181,9 +203,10 @@ module CloseYourIt
181
203
  # sorgente) e per impostare la sorgente solo via `.named` (child logger, parità dart/js — CYRB-8).
182
204
  # Le app usano `CloseYourIt.log` / `CloseYourIt.logger`.
183
205
  def emit_log(level, message, source: nil, attributes: {})
206
+ return nil if in_diagnostic?
184
207
  return nil unless logs_enabled?
185
208
  return nil if log_below_min_level?(level)
186
- return nil unless logs_sampled?
209
+ return record_drop(:sampled) unless logs_sampled?
187
210
 
188
211
  event = LogEvent.new(message, level: level, attributes: attributes,
189
212
  logger: source, configuration: configuration)
@@ -197,6 +220,37 @@ module CloseYourIt
197
220
  logs_enabled?
198
221
  end
199
222
 
223
+ # Vero se una riga del broadcast Rails.logger va scartata: nomina un'eccezione già presente in
224
+ # `excluded_exceptions`, oppure combacia con `excluded_log_patterns`.
225
+ #
226
+ # Serve perché un'eccezione esclusa dal canale ERRORI rientrava da quello dei LOG: Rails la
227
+ # registra con `logger.error`, il broadcast inoltrava la riga senza guardarla, e il rumore che
228
+ # `excluded_exceptions` aveva appena scartato ricompariva come log-entry. Il 2026-07-30 erano
229
+ # 48.000 voci su 49.985 nello stream, quasi tutte `ActionController::RoutingError` da favicon
230
+ # mancanti e scansioni di bot — che è nella lista di default dalla prima riga (CYRB-17).
231
+ #
232
+ # `ignored_exception?` non è applicabile: qui la classe arriva come TESTO dentro il messaggio, non
233
+ # come oggetto con `ancestors` da confrontare. Da cui il match per sottostringa sui matcher String.
234
+ #
235
+ # Vale SOLO per il mirror automatico di Rails.logger: un `CloseYourIt.log` scritto di proposito
236
+ # dallo sviluppatore non si silenzia mai (chi lo scrive ha già deciso che vuole quella riga).
237
+ def ignored_log_message?(text)
238
+ text = text.to_s
239
+ return false if text.empty?
240
+
241
+ config = configuration
242
+ named = config.excluded_exceptions.any? do |matcher|
243
+ if matcher.is_a?(Regexp)
244
+ matcher.match?(text)
245
+ else
246
+ # Un matcher vuoto combacerebbe con qualunque riga: mai silenziare tutto per una lista sporca.
247
+ !matcher.empty? && text.include?(matcher)
248
+ end
249
+ end
250
+
251
+ named || config.excluded_log_patterns.any? { |pattern| pattern.match?(text) }
252
+ end
253
+
200
254
  # Forza l'invio dei log bufferizzati (chiamato anche allo shutdown del processo).
201
255
  def flush_logs
202
256
  @log_buffer&.flush
@@ -213,25 +267,112 @@ module CloseYourIt
213
267
  # Ordine critico: prima il buffer (accoda l'ultimo batch nel worker), poi il worker (lo drena).
214
268
  @log_buffer&.shutdown
215
269
  @client&.shutdown
270
+ # Riepilogo di fine-vita: l'app riceve lo snapshot dei contatori senza log rumorosi. Emesso una
271
+ # sola volta per sessione (uno shutdown esplicito seguito dall'at_exit non deve duplicarlo; il
272
+ # flag è azzerato a ogni init). Lo snapshot è best-effort: eventuali invii ancora in volo oltre il
273
+ # breve timeout di drain possono non esservi riflessi — il drain non blocca l'uscita (CYRB-5).
274
+ unless @shutdown_notified
275
+ @shutdown_notified = true
276
+ notify_diagnostic(:shutdown, stats: stats.to_h)
277
+ end
278
+ nil
279
+ end
280
+
281
+ # Ripristina le risorse di invio in un processo figlio dopo un fork. Chiamalo dai worker hook dei
282
+ # server che forkano (Puma `on_worker_boot`, Sidekiq/Unicorn `after_fork`) per ricreare SUBITO worker
283
+ # pool e log buffer nel figlio, invece di attendere la rilevazione lazy al primo evento. Opzionale:
284
+ # la gemma rileva comunque il cambio PID da sé (vedi #ensure_current_process!). Idempotente e sicuro
285
+ # anche se client/buffer non sono ancora stati materializzati (→ no-op, verranno creati lazy).
286
+ def after_fork
287
+ discard_inherited_client!
288
+ @pid = Process.pid
216
289
  nil
217
290
  end
218
291
 
219
- # Contatori diagnostici del client (accodati/scartati/spediti/falliti).
220
- # CloseYourIt.stats.to_h # => { enqueued: …, dropped: …, sent: …, failed: … }
292
+ # Contatori diagnostici del client (accodati/scartati/spediti/falliti/timeout).
293
+ # CloseYourIt.stats.to_h # => { enqueued: …, dropped: …, sent: …, failed: …, timeout: … }
221
294
  def stats
222
295
  @stats ||= Stats.new
223
296
  end
224
297
 
298
+ # Notifica una tappa del ciclo di vita di un evento all'hook `on_diagnostic` (se configurato).
299
+ # `event` è uno tra :enqueue, :send, :drop, :timeout, :shutdown; `details` un Hash privo di dati
300
+ # sensibili (es. `{ reason: :queue_full }`, `{ status: 429 }`). Chiamato da Client/Transport/
301
+ # BackgroundWorker/LogBuffer. Garanzie (CYRB-12):
302
+ # * NON invia telemetria: tocca solo l'hook dell'app e i contatori in-memory;
303
+ # * NON innesca loop di auto-monitoraggio: durante l'hook il guard è alzato, e finché è alzato
304
+ # sono soppressi SIA i `notify_diagnostic` annidati SIA le API di telemetria (`capture_*`/log,
305
+ # vedi #in_diagnostic?). Poiché l'accodamento della telemetria è sincrono nel thread dell'hook
306
+ # (solo l'invio HTTP è async), sopprimere l'accodamento chiude il loop anche cross-thread;
307
+ # * NON solleva: un hook difettoso è isolato (logga su internal_logger) e mai propagato nell'app.
308
+ # L'hook osserva sempre la configurazione CORRENTE: una notifica in volo che completa dopo una
309
+ # re-init raggiunge l'hook nuovo (best-effort, coerente col modello fire-and-forget).
310
+ def notify_diagnostic(event, **details)
311
+ hook = configuration.on_diagnostic
312
+ return nil if hook.nil?
313
+ return nil if Thread.current[DIAGNOSTIC_GUARD]
314
+
315
+ Thread.current[DIAGNOSTIC_GUARD] = true
316
+ begin
317
+ hook.call(event, details)
318
+ rescue StandardError => e
319
+ internal_logger.error("CloseYourIt diagnostic hook: #{e.class}: #{e.message}")
320
+ ensure
321
+ Thread.current[DIAGNOSTIC_GUARD] = false
322
+ end
323
+ nil
324
+ end
325
+
225
326
  private
226
327
 
328
+ # Vero se il thread corrente sta eseguendo l'hook diagnostico: le API di telemetria diventano no-op
329
+ # per impedire che un hook (mal scritto) generi altra telemetria e inneschi auto-monitoraggio.
330
+ def in_diagnostic?
331
+ Thread.current[DIAGNOSTIC_GUARD] == true
332
+ end
333
+
334
+ # Registra uno scarto: incrementa il contatore aggregato `dropped` e notifica `:drop` con il motivo
335
+ # (queue_full/before_send/sampled/error). Un unico punto tiene allineati snapshot e hook.
336
+ def record_drop(reason, **details)
337
+ stats.increment(:dropped)
338
+ notify_diagnostic(:drop, reason: reason, **details)
339
+ nil
340
+ end
341
+
227
342
  def client
343
+ ensure_current_process!
228
344
  @client ||= Client.new(configuration)
229
345
  end
230
346
 
231
347
  def log_buffer
348
+ ensure_current_process!
232
349
  @log_buffer ||= LogBuffer.new(client: client, configuration: configuration)
233
350
  end
234
351
 
352
+ # Rileva un fork confrontando il PID del processo in cui @client/@log_buffer sono stati materializzati
353
+ # con quello corrente. In un figlio forkato i due oggetti sono ereditati dal padre, ma i loro thread
354
+ # — il worker pool di Client e il TimerTask di LogBuffer — vivono solo nel padre (il fork copia il
355
+ # solo thread chiamante): gli eventi accodati non partirebbero mai, restando affidati a thread che
356
+ # esistono soltanto nel padre. Al cambio di PID abbandoniamo i riferimenti ereditati SENZA #shutdown
357
+ # (nessun join possibile sui thread del padre, e un flush del buffer rispedirebbe eventi del padre) →
358
+ # i getter li ricreano lazy sotto il processo corrente. Nel padre lo stato resta invariato.
359
+ def ensure_current_process!
360
+ pid = Process.pid
361
+ return if @pid == pid
362
+
363
+ discard_inherited_client! if @pid # non alla prima materializzazione (@pid nil): nulla da abbandonare
364
+ @pid = pid
365
+ end
366
+
367
+ # Sgancia i riferimenti a client e log buffer senza drenarli. Usato sia dalla rilevazione lazy del
368
+ # fork sia da #after_fork: nel figlio i thread sottostanti non esistono, quindi non c'è nulla da
369
+ # joinare e non si deve flushare (rispedirebbe gli eventi del padre). Il GC raccoglie i vecchi
370
+ # oggetti; i getter ne creano di nuovi al prossimo accesso.
371
+ def discard_inherited_client!
372
+ @client = nil
373
+ @log_buffer = nil
374
+ end
375
+
235
376
  # I log seguono il master switch del client + il proprio flag dedicato.
236
377
  def logs_enabled?
237
378
  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.1
4
+ version: 0.8.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,9 +80,12 @@ 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
88
+ - lib/closeyourit/trace_context.rb
71
89
  - lib/closeyourit/transport.rb
72
90
  - lib/closeyourit/version.rb
73
91
  homepage: https://github.com/bussolabs/closeyourit-ruby