closeyourit-ruby 0.10.0 → 0.11.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -1
  3. data/lib/closeyourit/background_worker.rb +14 -14
  4. data/lib/closeyourit/breadcrumb.rb +2 -2
  5. data/lib/closeyourit/breadcrumb_buffer.rb +2 -2
  6. data/lib/closeyourit/client.rb +24 -24
  7. data/lib/closeyourit/configuration.rb +104 -103
  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 +11 -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 +6 -6
  17. data/lib/closeyourit/job_context.rb +61 -0
  18. data/lib/closeyourit/line_cache.rb +4 -4
  19. data/lib/closeyourit/log_buffer.rb +10 -10
  20. data/lib/closeyourit/log_device.rb +18 -18
  21. data/lib/closeyourit/monitor.rb +2 -2
  22. data/lib/closeyourit/performance/request_profile.rb +5 -5
  23. data/lib/closeyourit/performance/rollup.rb +3 -3
  24. data/lib/closeyourit/rails/active_job_extension.rb +36 -31
  25. data/lib/closeyourit/rails/capture_exceptions.rb +3 -3
  26. data/lib/closeyourit/rails/error_subscriber.rb +4 -4
  27. data/lib/closeyourit/rails/log_broadcast.rb +12 -12
  28. data/lib/closeyourit/rails/net_http_patch.rb +22 -22
  29. data/lib/closeyourit/rails/query_source.rb +3 -3
  30. data/lib/closeyourit/rails/railtie.rb +29 -65
  31. data/lib/closeyourit/rails/request_body.rb +7 -7
  32. data/lib/closeyourit/rails/request_context.rb +27 -27
  33. data/lib/closeyourit/scope.rb +29 -29
  34. data/lib/closeyourit/scrubber.rb +50 -50
  35. data/lib/closeyourit/sidekiq/error_handler.rb +2 -2
  36. data/lib/closeyourit/sidekiq/job_metrics_middleware.rb +39 -17
  37. data/lib/closeyourit/stats.rb +8 -8
  38. data/lib/closeyourit/subscribers/job_performance.rb +102 -24
  39. data/lib/closeyourit/subscribers/request_performance.rb +4 -4
  40. data/lib/closeyourit/subscribers/slow_query.rb +42 -20
  41. data/lib/closeyourit/trace_context.rb +22 -22
  42. data/lib/closeyourit/transport.rb +22 -22
  43. data/lib/closeyourit/usage_registry.rb +17 -16
  44. data/lib/closeyourit/version.rb +1 -1
  45. data/lib/closeyourit-ruby.rb +125 -125
  46. metadata +2 -1
@@ -4,29 +4,29 @@ require "securerandom"
4
4
  require_relative "../scrubber"
5
5
 
6
6
  module CloseYourIt
7
- # Voce di log strutturata spedita all'ingest /logs (NON formato Sentry: i log sono uno stream con
8
- # message/level/attributes/logger). `message` E `attributes` passano dallo Scrubber (denylist +
9
- # pattern) come ErrorEvent — un log può contenere PII/segreti quanto un errore. `trace_id` è
10
- # congelato alla COSTRUZIONE (thread della richiesta) → correlazione log↔errori della stessa request
11
- # anche quando il flush avviene su un thread timer diverso.
7
+ # Structured log entry sent to the /logs ingest (NOT Sentry format: logs are a stream with
8
+ # message/level/attributes/logger). `message` AND `attributes` go through the Scrubber (denylist +
9
+ # patterns) like ErrorEvent — a log can contain as much PII/secrets as an error. `trace_id` is
10
+ # frozen at CONSTRUCTION (request thread) → log↔error correlation for the same request even when
11
+ # the flush happens on a different timer thread.
12
12
  class LogEvent < Event
13
- # Livelli canonici del backend (enum) + alias dei nomi stile ::Logger. Normalizzati QUI (fonte
14
- # unica) così ogni costruzione — via CloseYourIt.log, .logger o diretta — produce un livello valido.
15
- # L'indice in LEVELS è anche la severità numerica del livello (debug=0 … fatal=4): la STESSA mappa
16
- # di dart/js, così il gating per soglia (`logs_min_level`) filtra identico cross-SDK (CYRB-6).
13
+ # Backend canonical levels (enum) + aliases of ::Logger-style names. Normalized HERE (single
14
+ # source) so every construction — via CloseYourIt.log, .logger or directly — yields a valid level.
15
+ # The index in LEVELS is also the level's numeric severity (debug=0 … fatal=4): the SAME map as
16
+ # dart/js, so threshold gating (`logs_min_level`) filters identically across SDKs (CYRB-6).
17
17
  LEVELS = %w[debug info warning error fatal].freeze
18
18
  LEVEL_ALIASES = { "warn" => "warning", "err" => "error", "unknown" => "fatal" }.freeze
19
19
 
20
- # Normalizza qualsiasi livello (simbolo/stringa, maiuscole, alias) al nome canonico del backend.
21
- # Ignoto → "info". Metodo di classe: fonte unica riusata da `CloseYourIt.log` per il gating.
20
+ # Normalizes any level (symbol/string, uppercase, alias) to the backend canonical name.
21
+ # Unknown → "info". Class method: single source reused by `CloseYourIt.log` for gating.
22
22
  def self.normalize_level(level)
23
23
  value = level.to_s.downcase
24
24
  value = LEVEL_ALIASES.fetch(value, value)
25
25
  LEVELS.include?(value) ? value : "info"
26
26
  end
27
27
 
28
- # Severità numerica del livello (indice in LEVELS), identica alla mappa cross-SDK. Usata per il
29
- # confronto con `logs_min_level` prima di costruire/spedire il log.
28
+ # Numeric severity of the level (index in LEVELS), identical to the cross-SDK map. Used for the
29
+ # comparison with `logs_min_level` before building/sending the log.
30
30
  def self.severity(level)
31
31
  LEVELS.index(normalize_level(level))
32
32
  end
@@ -38,9 +38,9 @@ module CloseYourIt
38
38
  @attributes = attributes || {}
39
39
  @logger = logger
40
40
  @scrubber = Scrubber.new(configuration)
41
- # trace_id catturato QUI, sul thread della richiesta: il LogEvent viene bufferizzato e flushato
42
- # su un thread timer diverso, dove lo Scope corrente è di un'ALTRA richiesta (o vuoto) → leggerlo
43
- # lazy in to_h romperebbe la correlazione log↔errore. Lo congeliamo alla costruzione.
41
+ # trace_id captured HERE, on the request thread: the LogEvent is buffered and flushed on a
42
+ # different timer thread, where the current Scope belongs to ANOTHER request (or is empty) →
43
+ # reading it lazily in to_h would break log↔error correlation. We freeze it at construction.
44
44
  @trace_id = CloseYourIt::Scope.current.trace_id
45
45
  end
46
46
 
@@ -71,7 +71,7 @@ module CloseYourIt
71
71
  @scrubber.filter_params(deep_stringify_keys(@attributes))
72
72
  end
73
73
 
74
- # Chiavi sempre stringa (anche annidate): coerenza col payload JSON e con la denylist dello Scrubber.
74
+ # Keys always strings (nested too): consistent with the JSON payload and the Scrubber denylist.
75
75
  def deep_stringify_keys(value)
76
76
  case value
77
77
  when Hash then value.each_with_object({}) { |(key, val), acc| acc[key.to_s] = deep_stringify_keys(val) }
@@ -4,9 +4,9 @@ require "securerandom"
4
4
  require_relative "../scrubber"
5
5
 
6
6
  module CloseYourIt
7
- # Messaggio diagnostico esplicito (`CloseYourIt.capture_message`) nel formato evento Sentry
8
- # (`message.formatted` + level). Fonde lo Scope corrente come ErrorEvent. Il messaggio passa dallo
9
- # Scrubber (pattern) come `exception.message` di ErrorEvent — parità PII cross-evento.
7
+ # Explicit diagnostic message (`CloseYourIt.capture_message`) in Sentry event format
8
+ # (`message.formatted` + level). Merges the current Scope like ErrorEvent. The message goes through
9
+ # the Scrubber (patterns) like ErrorEvent's `exception.message` — PII parity across events.
10
10
  class MessageEvent < Event
11
11
  def initialize(message, level:, configuration:)
12
12
  super(configuration)
@@ -21,7 +21,7 @@ module CloseYourIt
21
21
  "timestamp" => @occurred_at,
22
22
  "platform" => "ruby",
23
23
  "level" => @level,
24
- # Correlazione log↔errori: stesso trace_id dei log della medesima richiesta (parità con ErrorEvent).
24
+ # Log↔error correlation: same trace_id as the logs of the same request (parity with ErrorEvent).
25
25
  "trace_id" => CloseYourIt::Scope.current.trace_id,
26
26
  "environment" => environment,
27
27
  "release" => @configuration.release,
@@ -4,10 +4,10 @@ require "securerandom"
4
4
  require_relative "../event"
5
5
 
6
6
  module CloseYourIt
7
- # Payload `kind=performance_issue` per la pipeline metriche (`/api/v1/projects/:id/metrics`).
8
- # È un VERDETTO aggregato (N+1, slow request, slow external HTTP), non una metrica grezza:
9
- # `subtype` lo qualifica, `trace_id` lo correla a log/errori della stessa richiesta. Lo SQL è già
10
- # offuscato (è il fingerprint del profilo). I campi nil vengono omessi (slow_request non ha sql).
7
+ # `kind=performance_issue` payload for the metrics pipeline (`/api/v1/projects/:id/metrics`).
8
+ # It is an aggregate VERDICT (N+1, slow request, slow external HTTP), not a raw metric:
9
+ # `subtype` qualifies it, `trace_id` correlates it with logs/errors of the same request. The SQL is
10
+ # already obfuscated (it is the profile fingerprint). Nil fields are omitted (slow_request has no sql).
11
11
  class PerformanceIssueEvent < Event
12
12
  def initialize(attrs, configuration)
13
13
  super(configuration)
@@ -5,10 +5,10 @@ require_relative "../event"
5
5
  require_relative "../scrubber"
6
6
 
7
7
  module CloseYourIt
8
- # Payload `kind=slow_method` per la pipeline metriche. Di default solo label + durata + posizione.
9
- # Gli argomenti del metodo sono inviati SOLO se `capture_method_arguments` (opt-in, default OFF):
10
- # posizionali per indice, kwargs per nome (scrub della chiave sensibile), valore via `inspect`
11
- # troncato per sicurezza JSON. Vedi PDR §9.
8
+ # `kind=slow_method` payload for the metrics pipeline. By default only label + duration + location.
9
+ # Method arguments are sent ONLY with `capture_method_arguments` (opt-in, default OFF):
10
+ # positional by index, kwargs by name (sensitive key scrubbing), value via `inspect`
11
+ # truncated for JSON safety. See PDR §9.
12
12
  class SlowMethodEvent < Event
13
13
  def initialize(label, duration_ms, location, configuration, args: nil, kwargs: nil)
14
14
  super(configuration)
@@ -41,8 +41,8 @@ module CloseYourIt
41
41
 
42
42
  private
43
43
 
44
- # Argomenti — SOLO se capture_method_arguments (opt-in). Posizionali per indice; kwargs per nome con
45
- # scrub della chiave sensibile (denylist password/token/…). Valore = inspect troncato (JSON-safe).
44
+ # Arguments — ONLY with capture_method_arguments (opt-in). Positional by index; kwargs by name with
45
+ # sensitive key scrubbing (denylist password/token/…). Value = truncated inspect (JSON-safe).
46
46
  def arguments
47
47
  return nil unless @configuration.capture_method_arguments
48
48
 
@@ -6,8 +6,8 @@ require_relative "../scrubber"
6
6
  require_relative "../scope"
7
7
 
8
8
  module CloseYourIt
9
- # Payload `kind=slow_query` per la pipeline metriche (`/api/v1/projects/:id/metrics`).
10
- # Lo SQL è offuscato (binds esclusi) — vedi PDR §9.
9
+ # `kind=slow_query` payload for the metrics pipeline (`/api/v1/projects/:id/metrics`).
10
+ # The SQL is obfuscated (binds excluded) — see PDR §9.
11
11
  class SlowQueryEvent < Event
12
12
  def initialize(payload, duration_ms, configuration)
13
13
  super(configuration)
@@ -47,8 +47,8 @@ module CloseYourIt
47
47
  connection.adapter_name.to_s.downcase
48
48
  end
49
49
 
50
- # Valori dei bind — SOLO se capture_query_bindings (opt-in, default OFF). Scrub per nome colonna
51
- # (denylist password/token/…); il valore è reso come stringa per sicurezza JSON.
50
+ # Bind values — ONLY with capture_query_bindings (opt-in, default OFF). Scrubbed by column name
51
+ # (denylist password/token/…); the value is rendered as a string for JSON safety.
52
52
  def bindings
53
53
  return nil unless @configuration.capture_query_bindings
54
54
 
@@ -3,8 +3,8 @@
3
3
  require_relative "events/slow_method_event"
4
4
 
5
5
  module CloseYourIt
6
- # Cronometra blocchi/metodi con `CLOCK_MONOTONIC` e invia un `slow_method` se la durata supera la
7
- # soglia. Gli argomenti sono inviati solo se `capture_method_arguments` (opt-in) — vedi SlowMethodEvent.
6
+ # Times blocks/methods with `CLOCK_MONOTONIC` and sends a `slow_method` when the duration exceeds
7
+ # the threshold. Arguments are sent only with `capture_method_arguments` (opt-in) — see SlowMethodEvent.
8
8
  module Instrumenter
9
9
  module_function
10
10
 
@@ -13,10 +13,10 @@ module CloseYourIt
13
13
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
14
  yield
15
15
  ensure
16
- # `measure` gira DENTRO il metodo dell'app (Monitor fa prepend): un guasto qui — soglia non
17
- # configurata, ingest irraggiungibile — solleverebbe in un chiamante che aveva già il suo
18
- # risultato. È l'unico ingresso della gemma che era scoperto: ora è come tutti gli altri
19
- # (CYRB-24). Un'eccezione del blocco misurato continua invece a propagare intatta.
16
+ # `measure` runs INSIDE the app's method (Monitor prepends): a failure here — threshold not
17
+ # configured, unreachable ingest — would raise in a caller that already had its result. It was
18
+ # the only unguarded entry point of the gem: now it is like all the others (CYRB-24). An
19
+ # exception from the measured block, instead, keeps propagating untouched.
20
20
  begin
21
21
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
22
22
  report(label, duration_ms, location, args: args, kwargs: kwargs)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module CloseYourIt
7
+ # Versioned attempt metadata. Missing clocks stay unknown, never an invented zero.
8
+ module JobContext
9
+ def self.build(framework:, name:, queue: nil, job_id: nil, attempt: nil, retry_count: :derive,
10
+ enqueued_at: nil, scheduled_at: nil, started_at: nil)
11
+ enqueued, scheduled, started = [ enqueued_at, scheduled_at, started_at ].map { |value| instant(value) }
12
+ due = enqueued && scheduled ? [ enqueued, scheduled ].max : enqueued
13
+ wait = (started - due) * 1000.0 if started && due
14
+ delay = (scheduled - enqueued) * 1000.0 if enqueued && scheduled
15
+ attempt = nil unless attempt.is_a?(Integer) && attempt.positive?
16
+ retry_count = attempt && attempt - 1 if retry_count == :derive
17
+ retry_count = nil unless retry_count.is_a?(Integer) && retry_count >= 0
18
+ {
19
+ "schema_version" => 1, "framework" => framework, "name" => bounded_text(name, 200) || "unknown",
20
+ "queue" => bounded_text(queue, 128), "job_id" => bounded_text(job_id, 256), "attempt" => attempt,
21
+ "retry_count" => retry_count, "attempt_outcome" => "unknown", "terminal" => nil,
22
+ "enqueued_at" => enqueued&.iso8601(6), "scheduled_at" => scheduled&.iso8601(6),
23
+ "started_at" => started&.iso8601(6), "duration_ms" => nil,
24
+ "queue_wait_ms" => wait && [ wait, 0.0 ].max,
25
+ "scheduled_delay_ms" => delay && [ delay, 0.0 ].max, "clock_skew" => !!(wait && wait.negative?)
26
+ }
27
+ end
28
+
29
+ def self.bounded_text(value, limit)
30
+ value if value.is_a?(String) && value.length.between?(1, limit) && !value.match?(/[\x00-\x1f\x7f]/)
31
+ end
32
+
33
+ def self.sidekiq_time(value, version:)
34
+ return value unless value.is_a?(Numeric)
35
+
36
+ version.to_s.split(".").first.to_i >= 8 ? value / 1000.0 : value
37
+ end
38
+
39
+ def self.instant(value)
40
+ case value
41
+ when Time then value.getutc
42
+ when Numeric then Time.at(value).utc if value.finite?
43
+ when String
44
+ if value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
45
+ DateTime.rfc3339(value)
46
+ Time.iso8601(value).utc
47
+ end
48
+ end
49
+ rescue ArgumentError, RangeError
50
+ nil
51
+ end
52
+
53
+ def self.log(context, configuration)
54
+ return unless configuration.monitor_jobs && configuration.job_lifecycle_logs
55
+
56
+ CloseYourIt.log(:info, "Background job attempt finished", logger: "closeyourit.jobs", contexts: { job: context })
57
+ rescue StandardError
58
+ CloseYourIt.internal_logger.warn("CloseYourIt job lifecycle emission failed")
59
+ end
60
+ end
61
+ end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- # Cache in-process delle righe dei file sorgente, per le context lines dei frame
5
- # (pre_context/context_line/post_context). Bounded: oltre MAX_FILES si svuota per intero —
6
- # semplice e sufficiente (i file di un'app in errore sono pochi e ricorrenti). Thread-safe.
4
+ # In-process cache of source file lines, for the frames' context lines
5
+ # (pre_context/context_line/post_context). Bounded: beyond MAX_FILES it is emptied entirely —
6
+ # simple and sufficient (the files of an app in error are few and recurring). Thread-safe.
7
7
  module LineCache
8
8
  MAX_FILES = 200
9
9
 
@@ -11,7 +11,7 @@ module CloseYourIt
11
11
  @mutex = Mutex.new
12
12
 
13
13
  class << self
14
- # Righe del file (chomp-ate), oppure nil se non leggibile o path sintetico ("(eval)", "(irb)").
14
+ # The file's lines (chomped), or nil if unreadable or a synthetic path ("(eval)", "(irb)").
15
15
  def lines(path)
16
16
  return nil if path.nil? || path.empty? || path.start_with?("(")
17
17
 
@@ -3,11 +3,11 @@
3
3
  require "concurrent"
4
4
 
5
5
  module CloseYourIt
6
- # Buffer in-memory thread-safe dei log: accumula i LogEvent e li flusha in batch verso /logs quando
7
- # raggiungono `logs_batch_size`, allo scadere di `logs_flush_interval` (timer), o allo shutdown.
8
- # Riduce le richieste HTTP — i log sono alto-volume, a differenza di errori/metriche uno-a-uno.
6
+ # Thread-safe in-memory log buffer: collects LogEvents and flushes them in batches to /logs when
7
+ # they reach `logs_batch_size`, when `logs_flush_interval` expires (timer), or at shutdown.
8
+ # Cuts HTTP requests — logs are high-volume, unlike one-to-one errors/metrics.
9
9
  class LogBuffer
10
- attr_reader :timer # esposto per i test (verifica dell'intervallo configurato)
10
+ attr_reader :timer # exposed for tests (checks the configured interval)
11
11
 
12
12
  def initialize(client:, configuration:)
13
13
  @client = client
@@ -33,12 +33,12 @@ module CloseYourIt
33
33
 
34
34
  @client.flush_logs(batch)
35
35
  rescue StandardError => e
36
- # Il flush gira sia sul thread della richiesta (batch pieno) sia sul thread timer: un to_h /
37
- # before_send che solleva su UN evento non deve propagare nell'app né uccidere il TimerTask
38
- # (che altrimenti smette di flushare → log accumulati e persi). Ingoiato e loggato.
36
+ # The flush runs both on the request thread (full batch) and on the timer thread: a to_h /
37
+ # before_send raising on ONE event must not propagate into the app nor kill the TimerTask
38
+ # (which would otherwise stop flushing → logs pile up and are lost). Swallowed and logged.
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.
40
+ # The batch has already been drained: if flush_logs raises, those logs are lost. Count them as
41
+ # drops so the failure is not silent (CYRB-12). `batch.to_a` is nil-safe.
42
42
  batch.to_a.size.times do
43
43
  CloseYourIt.stats.increment(:dropped)
44
44
  CloseYourIt.notify_diagnostic(:drop, reason: :error)
@@ -60,7 +60,7 @@ module CloseYourIt
60
60
  end
61
61
  end
62
62
 
63
- # Avvia il timer di flush periodico alla prima voce (una sola volta).
63
+ # Starts the periodic flush timer on the first entry (only once).
64
64
  def ensure_timer
65
65
  return if @timer
66
66
 
@@ -1,28 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- # Oggetto Logger-compatibile esposto come `CloseYourIt.logger`: ogni chiamata inoltra a
5
- # `CloseYourIt.emit_log` (→ ingest /logs). Usabile come logger esplicito dell'app, anche con attributes:
6
- # CloseYourIt.logger.warn("disco quasi pieno", disk: "sda1")
7
- # `warn` mappa sul livello `warning` (enum backend); supporta block e `::Logger#add` per drop-in.
4
+ # Logger-compatible object exposed as `CloseYourIt.logger`: every call forwards to
5
+ # `CloseYourIt.emit_log` (→ ingest /logs). Usable as the app's explicit logger, also with attributes:
6
+ # CloseYourIt.logger.warn("disk almost full", disk: "sda1")
7
+ # `warn` maps to the `warning` level (backend enum); supports blocks and `::Logger#add` as a drop-in.
8
8
  #
9
- # La sorgente del log (campo `logger` del payload) si imposta SOLO via `.named` (child logger, parità
10
- # dart/js) o via costruttore — MAI da una keyword dei metodi: così una chiave `logger` passata a un
11
- # metodo resta un attributo dati e non viene hijackata come sorgente (CYRB-8).
12
- # payments = CloseYourIt.logger.named("payments") # child con sorgente "payments"
9
+ # The log source (the payload's `logger` field) is set ONLY via `.named` (child logger, dart/js
10
+ # parity) or the constructor — NEVER from a method keyword: so a `logger` key passed to a method
11
+ # stays a data attribute and is not hijacked as the source (CYRB-8).
12
+ # payments = CloseYourIt.logger.named("payments") # child with source "payments"
13
13
  # payments.warn("retry", attempt: 3) # logger=payments, attributes={attempt:3}
14
- # CloseYourIt.logger.warn("disco pieno", logger: "/dev/sda1") # attributes={logger:"/dev/sda1"}
14
+ # CloseYourIt.logger.warn("disk full", logger: "/dev/sda1") # attributes={logger:"/dev/sda1"}
15
15
  class LogDevice
16
- # Severità numeriche ::Logger → livelli CloseYourIt (UNKNOWN→fatal).
16
+ # ::Logger numeric severities → CloseYourIt levels (UNKNOWN→fatal).
17
17
  SEVERITY_LEVELS = { 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal", 5 => "fatal" }.freeze
18
18
 
19
- # `source` = nome della sorgente del log (campo `logger` del payload). Preferire `.named` in app.
19
+ # `source` = name of the log source (the payload's `logger` field). Prefer `.named` in apps.
20
20
  def initialize(source = nil)
21
21
  @source = normalize_source(source)
22
22
  end
23
23
 
24
- # Child logger con sorgente esplicita (parità dart/js). Ritorna un NUOVO LogDevice: il logger su
25
- # cui è chiamato resta invariato (immutabile), come i child logger cross-SDK.
24
+ # Child logger with an explicit source (dart/js parity). Returns a NEW LogDevice: the logger it is
25
+ # called on stays unchanged (immutable), like the cross-SDK child loggers.
26
26
  # CloseYourIt.logger.named("payments").info("retry", attempt: 3)
27
27
  def named(source)
28
28
  self.class.new(source)
@@ -39,7 +39,7 @@ module CloseYourIt
39
39
  message
40
40
  end
41
41
 
42
- # Compat con ::Logger#add(severity, message = nil, progname = nil).
42
+ # Compatible with ::Logger#add(severity, message = nil, progname = nil).
43
43
  def add(severity, message = nil, progname = nil, &block)
44
44
  write(SEVERITY_LEVELS.fetch(severity.to_i, "info"), message || progname, {}, &block)
45
45
  end
@@ -48,16 +48,16 @@ module CloseYourIt
48
48
  private
49
49
 
50
50
  def write(level, message, attributes, &block)
51
- # Gate PRIMA del block: `logger.debug { dump_costoso }` non valuta il block se i log sono spenti.
51
+ # Gate BEFORE the block: `logger.debug { expensive_dump }` does not evaluate the block when logs are off.
52
52
  return unless CloseYourIt.logs_active?
53
53
 
54
54
  message = block.call if block
55
- # Sorgente e attributes separati: gli `attributes` (inclusa un'eventuale chiave `logger`) restano
56
- # dati; la sorgente è SOLO `@source` (via `.named`/costruttore) → nessuna collisione (CYRB-8).
55
+ # Source and attributes kept apart: the `attributes` (including any `logger` key) stay data;
56
+ # the source is ONLY `@source` (via `.named`/constructor) → no collision (CYRB-8).
57
57
  CloseYourIt.emit_log(level, message, source: @source, attributes: attributes)
58
58
  end
59
59
 
60
- # Sorgente vuota/blank → nessuna sorgente (nil); altrimenti stringa (il campo `logger` è testuale).
60
+ # Empty/blank source → no source (nil); otherwise a string (the `logger` field is textual).
61
61
  def normalize_source(source)
62
62
  return nil if source.nil?
63
63
 
@@ -3,7 +3,7 @@
3
3
  require_relative "instrumenter"
4
4
 
5
5
  module CloseYourIt
6
- # Macro per strumentare automaticamente un metodo:
6
+ # Macro to instrument a method automatically:
7
7
  #
8
8
  # class Report
9
9
  # include CloseYourIt::Monitor
@@ -11,7 +11,7 @@ module CloseYourIt
11
11
  # monitor :generate
12
12
  # end
13
13
  #
14
- # Wrappa il metodo via `Module#prepend` cronometrandolo, senza cambiarne firma/risultato.
14
+ # Wraps the method via `Module#prepend` timing it, without changing its signature/result.
15
15
  module Monitor
16
16
  def self.included(base)
17
17
  base.extend(ClassMethods)
@@ -2,11 +2,11 @@
2
2
 
3
3
  module CloseYourIt
4
4
  module Performance
5
- # Accumulatore per-richiesta (vive nello Scope, resettato a fine richiesta). Conta le query, le
6
- # raggruppa per [fingerprint SQL offuscato, call-site] (il pattern prosopite per l'N+1) e tiene le
7
- # chiamate HTTP esterne. Puro stato in memoria: il verdetto lo calcola Performance::Rollup.
5
+ # Per-request accumulator (lives in the Scope, reset at the end of the request). Counts queries,
6
+ # groups them by [obfuscated SQL fingerprint, call site] (the prosopite pattern for N+1) and keeps
7
+ # the external HTTP calls. Pure in-memory state: Performance::Rollup computes the verdict.
8
8
  class RequestProfile
9
- # Guard di memoria: cap ai gruppi/chiamate distinti tracciati (il conteggio totale resta esatto).
9
+ # Memory guard: cap on the distinct groups/calls tracked (the total count stays exact).
10
10
  MAX_GROUPS = 1000
11
11
  MAX_EXTERNAL = 500
12
12
 
@@ -19,7 +19,7 @@ module CloseYourIt
19
19
  @external_calls = []
20
20
  end
21
21
 
22
- # Una query non di sistema. Le query da cache non sono round-trip DB → non contano per l'N+1.
22
+ # A non-system query. Cached queries are not DB round-trips → they do not count for N+1.
23
23
  def add_query(fingerprint:, source:, duration_ms:, cached: false)
24
24
  return if cached
25
25
 
@@ -4,8 +4,8 @@ require_relative "../events/performance_issue_event"
4
4
 
5
5
  module CloseYourIt
6
6
  module Performance
7
- # Trasforma un RequestProfile (+ durata/route della richiesta) in 0..N verdetti PerformanceIssueEvent.
8
- # Le soglie vivono nella Configuration. Detection lato client; dedup/alert lato backend.
7
+ # Turns a RequestProfile (+ request duration/route) into 0..N PerformanceIssueEvent verdicts.
8
+ # Thresholds live in the Configuration. Detection on the client; dedup/alerts on the backend.
9
9
  class Rollup
10
10
  def self.call(...) = new(...).call
11
11
 
@@ -28,7 +28,7 @@ module CloseYourIt
28
28
 
29
29
  private
30
30
 
31
- # Un verdetto per ogni gruppo [fingerprint, call-site] che ha girato più di n_plus_one_threshold volte.
31
+ # One verdict for every [fingerprint, call-site] group that ran more than n_plus_one_threshold times.
32
32
  def n_plus_one_events
33
33
  @profile.query_groups.values.filter_map do |group|
34
34
  next unless group[:count] > @config.n_plus_one_threshold
@@ -2,34 +2,38 @@
2
2
 
3
3
  module CloseYourIt
4
4
  module Rails
5
- # Incluso in ActiveJob::Base (via railtie `on_load(:active_job)`): cattura gli errori dei job
6
- # (oggi persi) con il contesto del job. La logica vive in `.monitor`/`.report_discarded` per essere
7
- # testabile senza ActiveSupport/ActiveJob.
5
+ # Included in ActiveJob::Base (via the railtie's `on_load(:active_job)`): captures job errors
6
+ # (otherwise lost) with the job context. The logic lives in `.monitor`/`.report_discarded` so it
7
+ # is testable without ActiveSupport/ActiveJob.
8
8
  #
9
- # CYRB-19: `around_perform` gira DENTRO `perform_now`, mentre `retry_on`/`discard_on` (rescue_from)
10
- # sono valutati FUORI, dopo i callback. Catturare nell'around_perform significa quindi segnalare
11
- # OGNI tentativo — anche quelli che verranno ritentati con successo — perché il reporter vede
12
- # l'errore prima che il retry possa zittirlo, e ogni tentativo solleva una nuova istanza (la
13
- # deduplica interna, per-istanza, non interviene). Su ActiveJob 7.1+ deleghiamo la segnalazione ad
14
- # `after_discard`, che Rails invoca al fallimento definitivo (retry_on esauriti o eccezione non
15
- # gestita), mai sui tentativi che `retry_on` ritenta → una sola occorrenza per job.
9
+ # CYRB-19: `around_perform` runs INSIDE `perform_now`, while `retry_on`/`discard_on` (rescue_from)
10
+ # are evaluated OUTSIDE, after the callbacks. Capturing in around_perform therefore means reporting
11
+ # EVERY attempt — even those that will be retried successfully — because the reporter sees the
12
+ # error before the retry can silence it, and every attempt raises a new instance (the internal,
13
+ # per-instance dedup does not kick in). On ActiveJob 7.1+ we delegate reporting to `after_discard`,
14
+ # which Rails calls on final failure (retry_on exhausted or unhandled exception), never on the
15
+ # attempts `retry_on` retries → a single occurrence per job.
16
16
  #
17
- # Limiti noti (per costruzione di ActiveJob, non del client):
18
- # - `after_discard` NON scatta per gli errori intercettati da un `rescue_from` custom: sono gestiti
19
- # dall'app, quindi non li segnaliamo più come "non gestiti" (prima lo facevamo, impropriamente).
20
- # - I retry a livello di ADAPTER (es. Sidekiq) SENZA `retry_on` risollevano l'errore non gestito:
21
- # `after_discard` scatta a ogni esecuzione, quindi lì la de-duplicazione per tentativo non si
22
- # applica (comportamento invariato rispetto a prima).
23
- # Sulle versioni prive di `after_discard` restiamo al fallback legacy (cattura nell'around_perform).
17
+ # Known limits (by ActiveJob's design, not the client's):
18
+ # - `after_discard` does NOT fire for errors caught by a custom `rescue_from`: they are handled by
19
+ # the app, so we no longer report them as "unhandled" (we used to, improperly).
20
+ # - ADAPTER-level retries (e.g. Sidekiq) WITHOUT `retry_on` re-raise the unhandled error:
21
+ # `after_discard` fires on every execution, so per-attempt dedup does not apply there
22
+ # (behavior unchanged from before).
23
+ # On versions without `after_discard` we keep the legacy fallback (capture in around_perform).
24
24
  module ActiveJobExtension
25
- # Scope arricchito durante il `perform` tramandato ad `after_discard`. Legato all'ISTANZA del job
26
- # (ogni retry ne crea una nuova, deserializzata) → vive esattamente quanto serve, niente bleed tra
27
- # job o thread; il reset di fine `perform` lo sgancia solo dallo storage, non muta l'oggetto.
25
+ # Scope enriched during `perform`, handed over to `after_discard`. Bound to the job INSTANCE
26
+ # (every retry creates a new, deserialized one) → it lives exactly as long as needed, no bleed
27
+ # between jobs or threads; the end-of-`perform` reset only detaches it from storage, it does not
28
+ # mutate the object.
28
29
  STASHED_SCOPE_IVAR = :@__closeyourit_stashed_scope
29
30
 
30
31
  def self.included(base)
31
32
  base.around_perform do |job, block|
32
33
  CloseYourIt::Rails::ActiveJobExtension.monitor(job) { block.call }
34
+ rescue Exception # rubocop:disable Lint/RescueException
35
+ job.instance_variable_set(:@__closeyourit_job_outcome, :raised)
36
+ raise
33
37
  end
34
38
 
35
39
  return unless base.respond_to?(:after_discard)
@@ -39,9 +43,10 @@ module CloseYourIt
39
43
  end
40
44
  end
41
45
 
42
- # Esegue il job arricchendo lo scope con tag/context; resetta lo scope a fine job (no bleed tra
43
- # job sullo stesso thread). Su ActiveJob 7.1+ NON cattura l'errore (lo fa `after_discard` solo al
44
- # fallimento definitivo): qui tramanda lo scope al job e ri-solleva. Sul fallback legacy cattura.
46
+ # Runs the job enriching the scope with tags/context; resets the scope at the end of the job (no
47
+ # bleed between jobs on the same thread). On ActiveJob 7.1+ it does NOT capture the error
48
+ # (`after_discard` does, only on final failure): here it hands the scope to the job and re-raises.
49
+ # On the legacy fallback it captures.
45
50
  def self.monitor(job)
46
51
  return yield unless CloseYourIt.configuration.report_active_job_errors
47
52
 
@@ -60,10 +65,10 @@ module CloseYourIt
60
65
  end
61
66
  end
62
67
 
63
- # Aggancio `after_discard` (ActiveJob 7.1+): il job è definitivamente fallito, quindi segnaliamo
64
- # l'errore UNA sola volta (handled:false). Riprendiamo lo scope arricchito durante il `perform`
65
- # (tramandato da `monitor`) così il report conserva breadcrumb/tag/contesti raccolti nel job;
66
- # `apply_job_scope` rinfresca i campi standard con `executions` finale senza perdere i custom.
68
+ # `after_discard` hook (ActiveJob 7.1+): the job has failed for good, so we report the error ONLY
69
+ # once (handled:false). We restore the scope enriched during `perform` (handed over by `monitor`)
70
+ # so the report keeps the breadcrumbs/tags/contexts collected in the job; `apply_job_scope`
71
+ # refreshes the standard fields with the final `executions` without losing the custom ones.
67
72
  def self.report_discarded(job, exception)
68
73
  return unless CloseYourIt.configuration.report_active_job_errors
69
74
 
@@ -78,8 +83,8 @@ module CloseYourIt
78
83
  end
79
84
  end
80
85
 
81
- # true quando ActiveJob espone `after_discard` (7.1+): la segnalazione è delegata lì, così
82
- # `monitor` non cattura i tentativi intermedi. false → fallback legacy (cattura nell'around_perform).
86
+ # true when ActiveJob exposes `after_discard` (7.1+): reporting is delegated there, so `monitor`
87
+ # does not capture intermediate attempts. false → legacy fallback (capture in around_perform).
83
88
  def self.report_on_discard?(job)
84
89
  job.class.respond_to?(:after_discard)
85
90
  end
@@ -93,8 +98,8 @@ module CloseYourIt
93
98
  context["executions"] = job.executions if job.respond_to?(:executions)
94
99
  CloseYourIt.set_context("active_job", context) unless context.empty?
95
100
 
96
- # Un trace_id stabile per esecuzione (job_id è unico per run ActiveJob): log ed errore dello
97
- # stesso job condividono il trace_id → correlazione log↔errore anche fuori dal ciclo richiesta.
101
+ # A stable trace_id per execution (job_id is unique per ActiveJob run): logs and error of the
102
+ # same job share the trace_id → log↔error correlation outside the request cycle too.
98
103
  CloseYourIt::Scope.current.trace_id = job.job_id if job.respond_to?(:job_id)
99
104
  end
100
105
  end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module CloseYourIt
4
4
  module Rails
5
- # Rack middleware: cattura le eccezioni non gestite, le invia a CloseYourIt
6
- # e le **ri-solleva** (l'app continua a gestirle come prima). Rack puro, nessuna
7
- # dipendenza da Rails → testabile in isolamento.
5
+ # Rack middleware: captures unhandled exceptions, sends them to CloseYourIt
6
+ # and **re-raises** them (the app keeps handling them as before). Pure Rack, no
7
+ # Rails dependency → testable in isolation.
8
8
  class CaptureExceptions
9
9
  def initialize(app)
10
10
  @app = app