closeyourit-ruby 0.9.4 → 0.10.2

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -0
  3. data/lib/closeyourit/background_worker.rb +28 -9
  4. data/lib/closeyourit/breadcrumb.rb +2 -2
  5. data/lib/closeyourit/breadcrumb_buffer.rb +2 -2
  6. data/lib/closeyourit/client.rb +42 -29
  7. data/lib/closeyourit/configuration.rb +105 -101
  8. data/lib/closeyourit/event.rb +6 -6
  9. data/lib/closeyourit/events/error_event.rb +16 -16
  10. data/lib/closeyourit/events/job_metric_event.rb +8 -8
  11. data/lib/closeyourit/events/log_event.rb +17 -17
  12. data/lib/closeyourit/events/message_event.rb +4 -4
  13. data/lib/closeyourit/events/performance_issue_event.rb +4 -4
  14. data/lib/closeyourit/events/slow_method_event.rb +6 -6
  15. data/lib/closeyourit/events/slow_query_event.rb +4 -4
  16. data/lib/closeyourit/instrumenter.rb +12 -4
  17. data/lib/closeyourit/line_cache.rb +4 -4
  18. data/lib/closeyourit/log_buffer.rb +10 -10
  19. data/lib/closeyourit/log_device.rb +18 -18
  20. data/lib/closeyourit/monitor.rb +2 -2
  21. data/lib/closeyourit/performance/request_profile.rb +5 -5
  22. data/lib/closeyourit/performance/rollup.rb +3 -3
  23. data/lib/closeyourit/rails/active_job_extension.rb +33 -31
  24. data/lib/closeyourit/rails/capture_exceptions.rb +3 -3
  25. data/lib/closeyourit/rails/error_subscriber.rb +4 -4
  26. data/lib/closeyourit/rails/log_broadcast.rb +12 -12
  27. data/lib/closeyourit/rails/net_http_patch.rb +22 -22
  28. data/lib/closeyourit/rails/query_source.rb +3 -3
  29. data/lib/closeyourit/rails/railtie.rb +32 -52
  30. data/lib/closeyourit/rails/request_body.rb +7 -7
  31. data/lib/closeyourit/rails/request_context.rb +27 -27
  32. data/lib/closeyourit/scope.rb +29 -29
  33. data/lib/closeyourit/scrubber.rb +47 -48
  34. data/lib/closeyourit/sidekiq/error_handler.rb +2 -2
  35. data/lib/closeyourit/sidekiq/job_metrics_middleware.rb +10 -11
  36. data/lib/closeyourit/stats.rb +8 -8
  37. data/lib/closeyourit/subscribers/job_performance.rb +30 -19
  38. data/lib/closeyourit/subscribers/request_performance.rb +4 -4
  39. data/lib/closeyourit/subscribers/slow_query.rb +42 -20
  40. data/lib/closeyourit/trace_context.rb +22 -22
  41. data/lib/closeyourit/transport.rb +25 -20
  42. data/lib/closeyourit/usage_registry.rb +17 -16
  43. data/lib/closeyourit/version.rb +1 -1
  44. data/lib/closeyourit-ruby.rb +125 -125
  45. metadata +1 -1
@@ -1,73 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- # Rimozione PII dai payload: filtro chiavi sensibili, normalizzazione SQL, scrub messaggi.
5
- # Privacy-by-default — vedi PDR §9.
4
+ # PII removal from payloads: sensitive key filter, SQL normalization, message scrubbing.
5
+ # Privacy-by-default — see PDR §9.
6
6
  class Scrubber
7
7
  FILTERED = "[FILTERED]"
8
8
 
9
- # Token di chiavi sempre redatti (match per sottostringa, normalizzato). Allineato al regex
10
- # SENSITIVE_KEY di backend e client Dart (parità client-side) — vedi
11
- # Errors/Logs::Ingest::Normalize::SENSITIVE_KEY. Copre credenziali/segreti e PII:
9
+ # Key tokens that are always redacted (substring match, normalized). Aligned with the
10
+ # SENSITIVE_KEY regex of the backend and the Dart client (client-side parity) — see
11
+ # Errors/Logs::Ingest::Normalize::SENSITIVE_KEY. Covers credentials/secrets and PII:
12
12
  # /pass|secret|token|api[_-]?key|apikey|authorization|cookie|csrf|credit|card|cvv|ssn|iban|
13
13
  # email|phone|telephone|mobile|dob|birth|passport|bearer|session|pin|pan/i
14
- # `pass` copre password/passwd/pass_code/passkey/passphrase; `cookie` copre set-cookie;
15
- # `credit`+`card` coprono credit_card; `phone` copre telephone/smartphone; `birth` copre
16
- # date_of_birth. Match per sottostringa → privilegia l'over-redaction (privacy-by-default):
17
- # p.es. `company_name` (contiene `pan`) o `shipping` (contiene `pin`) vengono redatti — è
18
- # accettabile, meglio redigere troppo che perdere PII.
19
- # CYRB-3: la lista ometteva email/phone/dob → i bind delle query lente li leakavano nel pannello.
14
+ # `pass` covers password/passwd/pass_code/passkey/passphrase; `cookie` covers set-cookie;
15
+ # `credit`+`card` cover credit_card; `phone` covers telephone/smartphone; `birth` covers
16
+ # date_of_birth. Substring match → favors over-redaction (privacy-by-default):
17
+ # e.g. `company_name` (contains `pan`) or `shipping` (contains `pin`) get redacted — that is
18
+ # acceptable, better to redact too much than to leak PII.
19
+ # CYRB-3: the list omitted email/phone/dob → slow query binds leaked them into the panel.
20
20
  DENYLIST = %w[
21
21
  pass secret token api_key apikey authorization
22
22
  cookie csrf credit card cvv ssn iban
23
23
  email phone telephone mobile dob birth passport bearer session pin pan
24
24
  ].freeze
25
25
 
26
- # CYRB-23 — redazione INTEGRATA delle credenziali di autenticazione nel testo libero.
27
- # `filter_params`/`filter_value` coprono gli header STRUTTURATI (la chiave `authorization` è in
28
- # DENYLIST); un `Authorization: Bearer <token>` incollato dentro `exception.message` o dentro la
29
- # riga di un log è testo, non una chiave, e prima di questo ticket partiva in chiaro perché
30
- # `scrub_message` applicava solo i pattern configurati dall'utente (default `[]`).
26
+ # CYRB-23 — BUILT-IN redaction of authentication credentials in free text.
27
+ # `filter_params`/`filter_value` cover STRUCTURED headers (the `authorization` key is in
28
+ # DENYLIST); an `Authorization: Bearer <token>` pasted into `exception.message` or into a log
29
+ # line is text, not a key, and before this ticket it was sent in clear because `scrub_message`
30
+ # applied only the user-configured patterns (default `[]`).
31
31
  #
32
- # Forma del testo redatto (decisa qui una volta per tutti gli SDK — gemelli CYDA-26/CYJS-38/
33
- # CYPY-15): chiave e schema restano leggibili, sparisce SOLO la credenziale →
34
- # `Authorization: Bearer [FILTERED]`. Il messaggio resta diagnosticamente utile (si vede CHE
35
- # c'era un header e con quale schema) senza trasportare il segreto.
32
+ # Shape of the redacted text (decided here once for all SDKs — twins CYDA-26/CYJS-38/
33
+ # CYPY-15): key and scheme stay readable, ONLY the credential disappears →
34
+ # `Authorization: Bearer [FILTERED]`. The message stays useful for diagnosis (you see THAT
35
+ # there was a header and with which scheme) without carrying the secret.
36
36
  AUTH_SCHEME = /(?i:Bearer|Basic|Token)/
37
37
 
38
- # `token68` (RFC 7235) con padding `=` finale: la forma di una credenziale reale. Minimo 4
39
- # caratteri — un `Basic` di credenziali brevi è corto (`dTpw` = `u:p`); a filtrare la prosa
40
- # pensa NOT_PROSE, non la lunghezza.
38
+ # `token68` (RFC 7235) with trailing `=` padding: the shape of a real credential. Minimum 4
39
+ # characters — a `Basic` with short credentials is short (`dTpw` = `u:p`); filtering out prose
40
+ # is NOT_PROSE's job, not the length's.
41
41
  CREDENTIAL_TOKEN = %r{[A-Za-z0-9\-._~+/]{4,}={0,2}}
42
42
 
43
- # Guardia anti-falso-positivo per lo schema NUDO: quello che segue NON è una credenziale se ha
44
- # la forma di una parola scritta da un umano, cioè sole lettere in UNA di tre forme —
45
- # `minuscole`, `Capitalizzata`, `MAIUSCOLE` — e più corta di 20 caratteri. Salva "the bearer of
46
- # bad news", "invalid bearer credentials", "Basic HTTP authentication", "Bearer Token expired".
47
- # Tutto il resto è credenziale: entropia (cifre, `_`, `-`, `.`), maiuscole interne miste
48
- # (`dTpw`, base64) o lunghezza ≥ 20 (nessuna parola è così lunga, mentre un token opaco di sole
49
- # minuscole sì).
50
- # Trade-off accettato: un CamelCase subito dopo lo schema (`Token MyAppName`) viene redatto.
43
+ # False-positive guard for the BARE scheme: what follows is NOT a credential if it has the shape
44
+ # of a human-written word, i.e. letters only in ONE of three forms — `lowercase`, `Capitalized`,
45
+ # `UPPERCASE` — and shorter than 20 characters. It spares "the bearer of bad news",
46
+ # "invalid bearer credentials", "Basic HTTP authentication", "Bearer Token expired".
47
+ # Everything else is a credential: entropy (digits, `_`, `-`, `.`), mixed inner capitals
48
+ # (`dTpw`, base64) or length ≥ 20 (no word is that long, while an opaque lowercase-only token is).
49
+ # Accepted trade-off: a CamelCase word right after the scheme (`Token MyAppName`) gets redacted.
51
50
  PROSE_WORD = /(?:[a-z]{1,19}|[A-Z][a-z]{0,18}|[A-Z]{1,19})/
52
51
  NOT_PROSE = %r{(?!\[FILTERED\])(?!#{PROSE_WORD}(?![A-Za-z0-9\-._~+/=]))}
53
52
 
54
- # Valore di un header incollato nel testo: sequenza di pezzi non delimitatori, dove una stringa
55
- # fra virgolette conta come UN pezzo. NON `\S+`, che si mangerebbe la `)` di
56
- # "(Authorization: Bearer <token>)"; ma nemmeno una classe secca, che si fermerebbe alla prima
57
- # virgoletta lasciando in chiaro il segreto di `Token token="<token>"` (sintassi RFC 7235 e
58
- # `authenticate_with_http_token` di Rails) e di un frammento JSON incollato nel messaggio.
53
+ # Value of a header pasted into text: a sequence of non-delimiter pieces, where a quoted string
54
+ # counts as ONE piece. NOT `\S+`, which would eat the `)` of "(Authorization: Bearer <token>)";
55
+ # but not a plain class either, which would stop at the first quote leaving in clear the secret
56
+ # of `Token token="<token>"` (RFC 7235 syntax and Rails' `authenticate_with_http_token`) and of a
57
+ # JSON fragment pasted into the message.
59
58
  QUOTED = /"[^"\n]*"|'[^'\n]*'/
60
59
  HEADER_VALUE = /(?:#{QUOTED}|[^\s,;)\]}"'<>])+/
61
60
 
62
- # `Authorization:`/`authorization=` (anche `Proxy-Authorization`, anche `authorization_header`)
63
- # seguito dal valore: qui la chiave è esplicita, quindi si redige senza euristica di prosa —
64
- # over-redaction voluta, coerente con la DENYLIST. `[ \t]` e non `\s`: mai oltre il newline.
65
- # Il lookahead copre lo schema OPZIONALE: senza, su un testo già redatto il motore farebbe
66
- # backtracking sul ramo "senza schema" e redigerebbe la parola `Bearer` stessa (non idempotente).
61
+ # `Authorization:`/`authorization=` (also `Proxy-Authorization`, also `authorization_header`)
62
+ # followed by the value: here the key is explicit, so it is redacted without prose heuristics —
63
+ # intentional over-redaction, consistent with the DENYLIST. `[ \t]` not `\s`: never past newline.
64
+ # The lookahead covers the OPTIONAL scheme: without it, on already redacted text the engine would
65
+ # backtrack onto the "no scheme" branch and redact the word `Bearer` itself (not idempotent).
67
66
  AUTH_HEADER = /((?i:(?:proxy[_-]?)?authorization)[\w-]*["']?[ \t]*[:=][ \t]*)(?!(?:#{AUTH_SCHEME}[ \t]+)?\[FILTERED\])(#{AUTH_SCHEME}[ \t]+)?#{HEADER_VALUE}/
68
67
 
69
- # Schema nudo (`Bearer <token>` senza il nome dell'header) seguito da qualcosa che ha la forma
70
- # di una credenziale: qui la guardia di prosa serve, la parola "bearer" ricorre nei messaggi.
68
+ # Bare scheme (`Bearer <token>` without the header name) followed by something shaped like a
69
+ # credential: here the prose guard is needed, the word "bearer" shows up in messages.
71
70
  AUTH_CREDENTIAL = /(\b#{AUTH_SCHEME}[ \t]+)#{NOT_PROSE}#{CREDENTIAL_TOKEN}/
72
71
 
73
72
  STRING_LITERAL = /'(?:[^']|'')*'/
@@ -77,7 +76,7 @@ module CloseYourIt
77
76
  @configuration = configuration
78
77
  end
79
78
 
80
- # Filtra ricorsivamente Hash/Array sostituendo i valori delle chiavi sensibili.
79
+ # Recursively filters Hash/Array replacing the values of sensitive keys.
81
80
  def filter_params(value)
82
81
  case value
83
82
  when Hash
@@ -91,15 +90,15 @@ module CloseYourIt
91
90
  end
92
91
  end
93
92
 
94
- # Maschera i literal (stringa/numerici) nello SQL, preservando la struttura.
93
+ # Masks the (string/numeric) literals in SQL, preserving the structure.
95
94
  def obfuscate_sql(sql)
96
95
  return sql if sql.nil? || !@configuration.obfuscate_sql
97
96
 
98
97
  sql.to_s.gsub(STRING_LITERAL, "?").gsub(NUMERIC_LITERAL, "?")
99
98
  end
100
99
 
101
- # Regola integrata (schemi di autenticazione) PRIMA dei pattern utente: le due protezioni si
102
- # sommano, la prima non è disattivabile — privacy-by-default (CYRB-23).
100
+ # Built-in rule (authentication schemes) BEFORE the user patterns: the two protections add up,
101
+ # the first cannot be disabled — privacy-by-default (CYRB-23).
103
102
  def scrub_message(message)
104
103
  return message if message.nil?
105
104
 
@@ -112,7 +111,7 @@ module CloseYourIt
112
111
  end
113
112
  end
114
113
 
115
- # Valore di un singolo bind/argomento: redatto se il nome (colonna/parametro) è sensibile.
114
+ # Value of a single bind/argument: redacted when the name (column/parameter) is sensitive.
116
115
  def filter_value(key, value)
117
116
  sensitive_key?(key) ? FILTERED : value
118
117
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module CloseYourIt
4
4
  module Sidekiq
5
- # Error handler Sidekiq (registrato dal railtie solo se Sidekiq è presente). Sidekiq invoca
6
- # `call(exception, context, config)` e NON ri-solleva → qui catturiamo e basta.
5
+ # Sidekiq error handler (registered by the railtie only when Sidekiq is present). Sidekiq calls
6
+ # `call(exception, context, config)` and does NOT re-raise → here we just capture.
7
7
  class ErrorHandler
8
8
  def call(exception, context, _config = nil)
9
9
  apply_job_scope(context)
@@ -4,13 +4,12 @@ require_relative "../subscribers/job_performance"
4
4
 
5
5
  module CloseYourIt
6
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).
7
+ # Sidekiq server middleware (registered by the railtie only when Sidekiq is present) that measures
8
+ # the job's execution duration and queue wait, then delegates emitting over-threshold metrics to
9
+ # Subscribers::JobPerformance. It does not alter the job: it times around `yield` and re-raises
10
+ # any error unchanged (error capture belongs to the ErrorHandler). Measurement happens in `ensure`,
11
+ # so it is taken for jobs that raise too; telemetry is isolated (an error in our code never
12
+ # disturbs the host job). Effectively a no-op when `monitor_jobs` is OFF (the decision lives in #record).
14
13
  class JobMetricsMiddleware
15
14
  def initialize(subscriber = nil)
16
15
  @subscriber = subscriber
@@ -18,8 +17,8 @@ module CloseYourIt
18
17
 
19
18
  def call(_worker, job, queue)
20
19
  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.
20
+ # The queue wait is known at the START (now - enqueued_at); Sidekiq stores enqueued_at as epoch
21
+ # seconds. Computed before the yield so it does not include the job duration.
23
22
  latency = Subscribers::JobPerformance.latency_ms(job["enqueued_at"], now: Time.now.utc)
24
23
  yield
25
24
  ensure
@@ -47,8 +46,8 @@ module CloseYourIt
47
46
  @subscriber ||= Subscribers::JobPerformance.new
48
47
  end
49
48
 
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.
49
+ # 1-based execution number. Sidekiq does NOT set `retry_count` on the first run (nil), sets it to 0
50
+ # on the first retry, 1 on the second, ... → attempt = retry_count + 2 when present, 1 on the first run.
52
51
  def attempt(job)
53
52
  count = job["retry_count"]
54
53
  count.nil? ? 1 : count + 2
@@ -3,11 +3,11 @@
3
3
  require "concurrent"
4
4
 
5
5
  module CloseYourIt
6
- # Contatori diagnostici thread-safe del client: quanti eventi sono stati accodati,
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.
6
+ # Thread-safe client diagnostic counters: how many events were enqueued, dropped (full queue /
7
+ # before_send / sampling), sent successfully, failed (network or non-2xx status) and, among the
8
+ # failed, how many by network timeout. They make the silent failures of the fire-and-forget
9
+ # transport visible. `timeout` is a sub-count of `failed` (a timeout is still a send failure):
10
+ # we keep them apart to isolate connectivity problems from non-2xx.
11
11
  #
12
12
  # CloseYourIt.stats.to_h # => { enqueued: 12, dropped: 0, sent: 11, failed: 1, timeout: 1 }
13
13
  class Stats
@@ -31,9 +31,9 @@ module CloseYourIt
31
31
  @counters.transform_values(&:value)
32
32
  end
33
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.
34
+ # Thread-safe snapshot of the counters (each value read atomically). It is the same Hash as
35
+ # `to_h`, with an explicit name for the "read the local diagnostics" use case (CYRB-12): a pure
36
+ # in-memory read, it never sends telemetry.
37
37
  alias_method :snapshot, :to_h
38
38
 
39
39
  def reset!
@@ -5,23 +5,23 @@ require_relative "../events/job_metric_event"
5
5
 
6
6
  module CloseYourIt
7
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`.
8
+ # Measures execution duration and queue wait (queue latency) of background jobs — ActiveJob and
9
+ # Sidekiq — and, beyond the configured thresholds, emits performance_issue metrics (subtypes
10
+ # `slow_job` and `job_queue_latency`). PURE logic WITHOUT shared state: everything comes in as
11
+ # parameters, so concurrent jobs on the same thread/process do not contaminate each other. The
12
+ # wiring to ActiveSupport::Notifications and the Sidekiq middleware lives elsewhere (Railtie /
13
+ # JobMetricsMiddleware). Honors the `monitor_jobs` master switch, the thresholds and `jobs_sample_rate`.
14
14
  class JobPerformance
15
15
  def initialize(configuration = nil)
16
16
  @configuration = configuration
17
17
  end
18
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).
19
+ # Single decision point: from the measured values (duration and/or wait) it builds 0..2 metrics,
20
+ # applies the thresholds (strict `>`, so X makes no noise and X+1 does) and sampling, then sends
21
+ # them fire-and-forget. `duration_ms` and `queue_latency_ms` are optional: ActiveJob provides them
22
+ # from two separate hooks (perform_start → wait, perform → duration), Sidekiq both in one call.
23
+ # Sampling is applied ONLY to candidates already beyond the threshold (normal jobs neither
24
+ # consume nor generate anything).
25
25
  def record(job_class:, queue: nil, adapter: nil, duration_ms: nil, queue_latency_ms: nil,
26
26
  attempt: nil, trace_id: nil)
27
27
  config = configuration
@@ -38,19 +38,20 @@ module CloseYourIt
38
38
  nil
39
39
  end
40
40
 
41
- # Hook `perform_start.active_job`: l'attesa in coda è nota appena il job parte (now - enqueued_at).
41
+ # `perform_start.active_job` hook: the queue wait is known as soon as the job starts (now - the
42
+ # moment it was due to run).
42
43
  def active_job_started(job, now: Time.now.utc)
43
44
  record(
44
45
  job_class: job.class.name,
45
46
  queue: (job.queue_name if job.respond_to?(:queue_name)),
46
47
  adapter: "active_job",
47
- queue_latency_ms: self.class.latency_ms(enqueued_at(job), now: now),
48
+ queue_latency_ms: self.class.latency_ms(due_at(job), now: now),
48
49
  attempt: (job.executions if job.respond_to?(:executions)),
49
50
  trace_id: (job.job_id if job.respond_to?(:job_id))
50
51
  )
51
52
  end
52
53
 
53
- # Hook `perform.active_job`: a fine esecuzione la durata è `event.duration` (ms).
54
+ # `perform.active_job` hook: at the end of execution the duration is `event.duration` (ms).
54
55
  def active_job_performed(job, duration_ms)
55
56
  record(
56
57
  job_class: job.class.name,
@@ -62,10 +63,10 @@ module CloseYourIt
62
63
  )
63
64
  end
64
65
 
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`.
66
+ # Normalizes `enqueued_at` (Time, Numeric epoch in seconds like Sidekiq, or ISO8601 String) into
67
+ # a wait (ms) relative to `now`. nil or unparsable → nil: no wait metric (adapter that does not
68
+ # populate the enqueue instant). Clamped to 0 when negative (clock skew, enqueue "in the future"):
69
+ # the ingest schema requires `duration_ms >= 0`.
69
70
  def self.latency_ms(enqueued_at, now:)
70
71
  started = to_time(enqueued_at)
71
72
  return nil if started.nil?
@@ -94,6 +95,16 @@ module CloseYourIt
94
95
  job.enqueued_at if job.respond_to?(:enqueued_at)
95
96
  end
96
97
 
98
+ # A job delayed on purpose (set(wait:), retry_on backoff) is due at scheduled_at, not when it was
99
+ # enqueued: the planned delay is not queue wait (CYRB-27). No enqueued_at still means no metric.
100
+ def due_at(job)
101
+ enqueued = self.class.to_time(enqueued_at(job))
102
+ scheduled = self.class.to_time(job.scheduled_at) if job.respond_to?(:scheduled_at)
103
+ return enqueued if enqueued.nil? || scheduled.nil?
104
+
105
+ [ enqueued, scheduled ].max
106
+ end
107
+
97
108
  def configuration
98
109
  @configuration || CloseYourIt.configuration
99
110
  end
@@ -5,10 +5,10 @@ require_relative "../performance/rollup"
5
5
 
6
6
  module CloseYourIt
7
7
  module Subscribers
8
- # A fine richiesta (process_action.action_controller) trasforma il RequestProfile accumulato nello
9
- # Scope in verdetti performance_issue e li spedisce (fire-and-forget). Lo Scope — e quindi il
10
- # profilo — viene azzerato subito dopo da RequestContext#call (ensure). Logica pura: il wiring ad
11
- # ActiveSupport::Notifications vive nel Railtie.
8
+ # At the end of the request (process_action.action_controller) turns the RequestProfile collected
9
+ # in the Scope into performance_issue verdicts and sends them (fire-and-forget). The Scope — and so
10
+ # the profile — is reset right after by RequestContext#call (ensure). Pure logic: the wiring to
11
+ # ActiveSupport::Notifications lives in the Railtie.
12
12
  class RequestPerformance
13
13
  def initialize(configuration = nil)
14
14
  @configuration = configuration
@@ -6,9 +6,9 @@ require_relative "../scope"
6
6
 
7
7
  module CloseYourIt
8
8
  module Subscribers
9
- # Riceve i dati di un evento `sql.active_record` e, se la query supera la soglia
10
- # (escludendo SCHEMA/CACHE/TRANSACTION), invia un evento `slow_query`.
11
- # Logica pura: il wiring ad ActiveSupport::Notifications vive nel Railtie.
9
+ # Receives the data of a `sql.active_record` event and, if the query exceeds the threshold
10
+ # (excluding SCHEMA/CACHE/TRANSACTION), sends a `slow_query` event.
11
+ # Pure logic: the wiring to ActiveSupport::Notifications lives in the Railtie.
12
12
  class SlowQuery
13
13
  IGNORED_NAMES = %w[SCHEMA CACHE TRANSACTION].freeze
14
14
 
@@ -16,6 +16,26 @@ module CloseYourIt
16
16
  @configuration = configuration
17
17
  end
18
18
 
19
+ # Entry point of the Railtie's sql.active_record block. `source` is a callable: the call site costs
20
+ # a full backtrace plus the Rails cleaner, so it is resolved at most once and only when needed.
21
+ def notify(payload, duration_ms, source)
22
+ resolved = false
23
+ site = nil
24
+ lazy_source = lambda do
25
+ site = source.call unless resolved
26
+ resolved = true
27
+ site
28
+ end
29
+ name = payload[:name]
30
+ sql = payload[:sql]
31
+ cached = payload.fetch(:cached, false)
32
+ record(name: name, duration_ms: duration_ms, sql: sql, cached: cached,
33
+ connection: payload[:connection], binds: payload[:binds],
34
+ type_casted_binds: payload[:type_casted_binds], source: lazy_source)
35
+ breadcrumb(name: name, sql: sql, duration_ms: duration_ms, cached: cached)
36
+ profile(name: name, sql: sql, duration_ms: duration_ms, cached: cached, source: lazy_source)
37
+ end
38
+
19
39
  def record(name:, duration_ms:, sql:, cached: false, connection: nil,
20
40
  binds: nil, type_casted_binds: nil, source: nil)
21
41
  config = @configuration || CloseYourIt.configuration
@@ -25,18 +45,18 @@ module CloseYourIt
25
45
 
26
46
  event = SlowQueryEvent.new(
27
47
  { name: name, sql: sql, cached: cached, connection: connection,
28
- binds: binds, type_casted_binds: type_casted_binds, source: source },
48
+ binds: binds, type_casted_binds: type_casted_binds, source: resolve(source) },
29
49
  duration_ms,
30
50
  config
31
51
  )
32
52
  CloseYourIt.capture_event(event)
33
53
  rescue StandardError
34
- # La telemetria non deve mai disturbare la query ospite.
54
+ # Telemetry must never disturb the host query.
35
55
  nil
36
56
  end
37
57
 
38
- # Breadcrumb per OGNI query non di sistema (non solo lente): SQL offuscato, niente bind.
39
- # Dà la cronologia "quali query prima del crash" allegata all'evento d'errore.
58
+ # Breadcrumb for EVERY non-system query (not only slow ones): obfuscated SQL, no binds.
59
+ # Gives the "which queries before the crash" history attached to the error event.
40
60
  def breadcrumb(name:, sql:, duration_ms:, cached: false)
41
61
  config = @configuration || CloseYourIt.configuration
42
62
  return if ignored_name?(name)
@@ -49,12 +69,12 @@ module CloseYourIt
49
69
  data: { "name" => name, "duration_ms" => duration_ms.to_f.round(2), "cached" => cached }
50
70
  )
51
71
  rescue StandardError
52
- # La telemetria non deve mai disturbare la query ospite.
72
+ # Telemetry must never disturb the host query.
53
73
  nil
54
74
  end
55
75
 
56
- # Spinge OGNI query non di sistema nel RequestProfile dello Scope (per la detection N+1 a fine
57
- # richiesta). Solo se detect_performance_issues: fingerprint = SQL offuscato + call-site.
76
+ # Pushes EVERY non-system query into the Scope's RequestProfile (for N+1 detection at the end of
77
+ # the request). Only with detect_performance_issues: fingerprint = obfuscated SQL + call site.
58
78
  def profile(name:, sql:, duration_ms:, cached: false, source: nil)
59
79
  config = @configuration || CloseYourIt.configuration
60
80
  return unless config.detect_performance_issues
@@ -62,15 +82,17 @@ module CloseYourIt
62
82
 
63
83
  CloseYourIt::Scope.current.performance_profile.add_query(
64
84
  fingerprint: scrubber(config).obfuscate_sql(sql),
65
- source: source, duration_ms: duration_ms, cached: cached
85
+ source: resolve(source), duration_ms: duration_ms, cached: cached
66
86
  )
67
87
  rescue StandardError
68
- # La telemetria non deve mai disturbare la query ospite.
88
+ # Telemetry must never disturb the host query.
69
89
  nil
70
90
  end
71
91
 
72
92
  private
73
93
 
94
+ def resolve(source) = source.respond_to?(:call) ? source.call : source
95
+
74
96
  def scrubber(config)
75
97
  @scrubber ||= Scrubber.new(config)
76
98
  end
@@ -79,16 +101,16 @@ module CloseYourIt
79
101
  name.nil? || IGNORED_NAMES.include?(name)
80
102
  end
81
103
 
82
- # Query esclusa dalla MISURA dei rallentamenti (config.excluded_query_patterns). Il filtro sta
83
- # solo qui, non in #breadcrumb né in #profile:
104
+ # Query excluded from slowdown MEASUREMENT (config.excluded_query_patterns). The filter lives
105
+ # only here, not in #breadcrumb nor in #profile:
84
106
  #
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.
107
+ # - breadcrumb: the "which queries before the crash" history has diagnostic value even when the
108
+ # query belongs to the framework — hiding it would make the sequence incomplete and misleading;
109
+ # - profile: it is per-request and serves N+1 detection, where a service table read many times
110
+ # is itself a symptom worth seeing.
89
111
  #
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.
112
+ # A slowdown is worth measuring if someone can act on it; a breadcrumb is worth keeping if it
113
+ # helps understanding. They are two different questions, and this list answers only the first.
92
114
  def excluded_sql?(config, sql)
93
115
  return false if sql.nil?
94
116
 
@@ -3,14 +3,14 @@
3
3
  require "securerandom"
4
4
 
5
5
  module CloseYourIt
6
- # Contesto di trace W3C (`traceparent`/`tracestate`, https://www.w3.org/TR/trace-context/).
7
- # NON è un tracer: non apre span né misura tempi. È un "propagation bridge" minimale (il ticket:
8
- # "Non costruire tracing custom completo") — adotta un contesto entrante valido facendo pass-through
9
- # di trace-id/parent-id/flag, oppure ne genera uno root, e sa serializzarsi negli header d'uscita.
10
- # Volutamente separato dai formati proprietari: sullo standard, senza dipendere da un vendor.
6
+ # W3C trace context (`traceparent`/`tracestate`, https://www.w3.org/TR/trace-context/).
7
+ # It is NOT a tracer: it opens no spans and measures no times. It is a minimal "propagation bridge"
8
+ # (the ticket: "do not build full custom tracing") — it adopts a valid incoming context passing
9
+ # through trace-id/parent-id/flags, or generates a root one, and serializes into outgoing headers.
10
+ # Deliberately separate from proprietary formats: standard-based, with no vendor dependency.
11
11
  class TraceContext
12
- # traceparent = version "-" trace-id "-" parent-id "-" trace-flags (55 char per la versione 00).
13
- # `rest` cattura eventuali campi futuri: ammessi solo da versioni > 00 (forward-compat), vietati su 00.
12
+ # traceparent = version "-" trace-id "-" parent-id "-" trace-flags (55 chars for version 00).
13
+ # `rest` captures any future fields: allowed only for versions > 00 (forward-compat), forbidden on 00.
14
14
  TRACEPARENT = /
15
15
  \A
16
16
  (?<version>[0-9a-f]{2})-
@@ -27,10 +27,10 @@ module CloseYourIt
27
27
  ZERO_PARENT_ID = ("0" * 16).freeze
28
28
  FLAG_SAMPLED = 0x01
29
29
 
30
- # tracestate: lista di membri `key=value` separati da virgola, max 32 (W3C §3.3.1).
30
+ # tracestate: comma-separated list of `key=value` members, max 32 (W3C §3.3.1).
31
31
  TRACESTATE_MAX_MEMBERS = 32
32
- # key: lowercase alnum iniziale + set ristretto (incluso `@`/`/` per le chiavi tenant@vendor).
33
- # value: caratteri stampabili 0x20–0x7E esclusi `,` (0x2C) e `=` (0x3D).
32
+ # key: leading lowercase alnum + restricted set (including `@`/`/` for tenant@vendor keys).
33
+ # value: printable characters 0x20–0x7E except `,` (0x2C) and `=` (0x3D).
34
34
  TRACESTATE_MEMBER = %r{\A[a-z0-9][a-z0-9_\-*/@]*=[\x20-\x2b\x2d-\x3c\x3e-\x7e]+\z}
35
35
 
36
36
  attr_reader :trace_id, :parent_id, :flags, :tracestate
@@ -43,9 +43,9 @@ module CloseYourIt
43
43
  end
44
44
 
45
45
  class << self
46
- # Adotta un traceparent entrante. Ritorna nil se malformato (→ il chiamante genera un root o
47
- # lascia il contesto assente). Pass-through: mantiene trace-id/parent-id/flag verbatim così il
48
- # bridge non inventa span. Il tracestate viene sanificato (membri invalidi scartati, cap a 32).
46
+ # Adopts an incoming traceparent. Returns nil when malformed (→ the caller generates a root or
47
+ # leaves the context absent). Pass-through: keeps trace-id/parent-id/flags verbatim so the
48
+ # bridge invents no spans. The tracestate is sanitized (invalid members dropped, capped at 32).
49
49
  def parse(traceparent, tracestate = nil)
50
50
  match = TRACEPARENT.match(traceparent.to_s.strip)
51
51
  return nil unless match
@@ -62,9 +62,9 @@ module CloseYourIt
62
62
  )
63
63
  end
64
64
 
65
- # Nuovo contesto root (nessun traceparent entrante valido). Genera trace-id (16 byte) e parent-id
66
- # (8 byte) casuali — SecureRandom è fork-safe, così un worker forkato non riusa gli id del padre.
67
- # `sampled` fissa il flag: un root che apriamo noi traccia di default.
65
+ # New root context (no valid incoming traceparent). Generates random trace-id (16 bytes) and
66
+ # parent-id (8 bytes) — SecureRandom is fork-safe, so a forked worker does not reuse the parent's
67
+ # ids. `sampled` sets the flag: a root we open traces by default.
68
68
  def generate(sampled: true)
69
69
  new(
70
70
  trace_id: SecureRandom.hex(16),
@@ -76,8 +76,8 @@ module CloseYourIt
76
76
 
77
77
  private
78
78
 
79
- # Trattiene solo i membri ben formati, nell'ordine originale, fino a 32. Ritorna nil se non ne
80
- # resta nessuno → così non propaghiamo mai un tracestate spazzatura o sovradimensionato.
79
+ # Keeps only well-formed members, in the original order, up to 32. Returns nil if none are left
80
+ # → so we never propagate a garbage or oversized tracestate.
81
81
  def sanitize_tracestate(tracestate)
82
82
  return nil if tracestate.nil?
83
83
 
@@ -92,14 +92,14 @@ module CloseYourIt
92
92
  (flags & FLAG_SAMPLED) != 0
93
93
  end
94
94
 
95
- # traceparent d'uscita, sempre versione 00 (l'unica che sappiamo emettere). I flag sono ri-emessi
96
- # per intero (i bit riservati vanno propagati as-is), formattati su due cifre esadecimali.
95
+ # Outgoing traceparent, always version 00 (the only one we can emit). Flags are re-emitted in full
96
+ # (reserved bits must be propagated as-is), formatted as two hex digits.
97
97
  def traceparent
98
98
  format("%s-%s-%s-%02x", CURRENT_VERSION, trace_id, parent_id, flags & 0xff)
99
99
  end
100
100
 
101
- # Header di propagazione W3C: traceparent (+ tracestate se presente). MAI `baggage`: può contenere
102
- # contesto interno/PII e non deve varcare il confine (ticket: "baggage sensibile non riceve header").
101
+ # W3C propagation headers: traceparent (+ tracestate if present). NEVER `baggage`: it may carry
102
+ # internal context/PII and must not cross the boundary (ticket: "sensitive baggage gets no header").
103
103
  def headers
104
104
  result = { "traceparent" => traceparent }
105
105
  result["tracestate"] = tracestate if tracestate && !tracestate.empty?