closeyourit-ruby 0.10.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 866fd0b6c34af299e0e538ddb181b1f6b3fd961da1e638226a81ad8b8ea15c0b
4
- data.tar.gz: eaf0d4530e9015f7353790aeffe104f04a2f80d46c4c7622ad7dad3302bd75fe
3
+ metadata.gz: e8e0fc4eed9289e8fbb3ac2b93e6bcf71c950200b24483be93ca9b03cbb5edf5
4
+ data.tar.gz: bec0fc92de3a19b85caa500e41953726a1693ca8f757cd98022b8b5287e83f76
5
5
  SHA512:
6
- metadata.gz: b4193d36f1123b89bdf11998ebcd5ea445b0b84c325ccae654d434d2d94158d6dd9c920b0e879167f0fba5cbaa7615f42a3d2cd11f3f61d8ade9f3eaee18917f
7
- data.tar.gz: 7a4d366fbf67b5720d878e2e1febbcda5500c6063d8f654e450c10b68789964a7c8b15390e23d4516d9220ea6cb2ffbde619900ec2871fe7b8b20fd23c967b8b
6
+ metadata.gz: f39ecd3584260bf7257fb8efc7fcf12222a2adec2fa2d1ba7ff3a59e368ae6c6e52921d5c83dda516c588946d718c4407aa934d6f495cfd9269621226fd58555
7
+ data.tar.gz: a634e8af76d32b12d1ffe210e73d3070c8059d15a55953caed9406d2c8db7fbeaac0f34bf800ee049bf9b4cdcb737d0239f3968978b970bb1175f42dfca3f0c5
data/README.md CHANGED
@@ -52,7 +52,7 @@ In CloseYourIt, area **Member → Project → tokens**, crea un token: ottieni i
52
52
  ```ruby
53
53
  # config/initializers/closeyourit.rb
54
54
  CloseYourIt.init do |c|
55
- c.endpoint_url = ENV["CLOSEYOURIT_ENDPOINT_URL"] # es. https://www.closeyour.it (host canonico)
55
+ c.endpoint_url = ENV["CLOSEYOURIT_ENDPOINT_URL"] # es. https://app.closeyour.it (host canonico)
56
56
  c.token = ENV["CLOSEYOURIT_TOKEN"] # Bearer secret del Projects::Token
57
57
  c.project_id = ENV["CLOSEYOURIT_PROJECT_ID"] # UUID del progetto su CloseYourIt
58
58
  c.environment = Rails.env
@@ -62,9 +62,8 @@ end
62
62
  **Senza `endpoint_url` / `token` / `project_id` la gemma è no-op** (nessun invio, nessun overhead). In
63
63
  `production` un `endpoint_url` `http://` viene rifiutato (no-op + warning): il token viaggerebbe in chiaro.
64
64
 
65
- > **Endpoint:** usa l'host canonico **`https://www.closeyour.it`**. La gemma segue fino a 2 redirect su POST
66
- > (preservando metodo e body), quindi anche l'apex `https://closeyour.it` (301 → www) funziona — ma puntare
67
- > direttamente a www risparmia un hop per ogni evento.
65
+ > **Endpoint:** usa l'host canonico **`https://app.closeyour.it`**. Le installazioni già configurate su
66
+ > `https://www.closeyour.it` continuano a funzionare: l'ingest su `www` resta inoltrato all'app.
68
67
 
69
68
  ### Opzioni
70
69
 
@@ -84,7 +83,9 @@ end
84
83
  | `slow_query_threshold_ms` | `100` | Soglia query lente |
85
84
  | `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 |
86
85
  | `slow_method_threshold_ms` | `200` | Soglia metodi lenti |
86
+ | `capture_method_arguments` | `false` | Allega gli argomenti di `measure`/`monitor` ai `slow_method`. **Attenzione**: gli argomenti sono spesso dati personali; la gemma redige chiavi sensibili, credenziali e `scrub_message_patterns`, non il resto |
87
87
  | `capture_request` | `true` | Cattura il contesto HTTP della richiesta (method/url/header allowlist) |
88
+ | `capture_request_body` | `true` | Allega i parametri del body (`request.data`) all'errore, scrubbati. Vale **solo con `send_pii = true`**: con la privacy spenta il body non parte mai |
88
89
  | `request_header_allowlist` | `Accept`, `Content-Type`, `User-Agent`, `Referer` | Header inviati (mai Authorization/Cookie) |
89
90
  | `breadcrumbs_enabled` | `true` | Cronologia (query offuscate + `add_breadcrumb`) allegata all'errore |
90
91
  | `max_breadcrumbs` | `100` | Dimensione max del ring buffer breadcrumbs |
@@ -92,7 +93,7 @@ end
92
93
  | `report_active_job_errors` | `true` | Cattura gli errori dei job ActiveJob/Solid Queue/Sidekiq |
93
94
  | `send_pii` | `false` | Master switch PII |
94
95
  | `obfuscate_sql` | `true` | Maschera i literal nello SQL |
95
- | `filter_parameters` | `[]` | Chiavi extra da redarre (mergiate con quelle di Rails) |
96
+ | `filter_parameters` | `[]` | Chiavi extra da redarre, in aggiunta a `Rails.application.config.filter_parameters` (unite automaticamente dalla Railtie) |
96
97
  | `scrub_message_patterns` | `[]` | Regexp **aggiuntive** da redarre da messaggi d'eccezione **e da `message` dei log**; le credenziali `Bearer`/`Basic`/`Token` sono già redatte di serie |
97
98
  | `logs_enabled` | `true` | Master switch dello stream di log strutturato |
98
99
  | `logs_min_level` | `:info` | Soglia minima: i log sotto (es. `debug`) non vengono inviati |
@@ -109,6 +110,7 @@ end
109
110
  | `slow_request_threshold_ms` | `1000` | Durata totale della richiesta (ms) oltre cui = `slow_request` |
110
111
  | `slow_external_threshold_ms` | `1000` | Durata di una singola HTTP esterna (ms) oltre cui = `slow_external_http` |
111
112
  | `capture_external_http` | `true` | Strumenta `Net::HTTP` per rilevare le HTTP esterne (effettivo solo con `detect_performance_issues`) |
113
+ | `job_lifecycle_logs` | `false` | Log informativo per esito di tentativo osservato, opt-in. |
112
114
  | `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)) |
113
115
  | `slow_job_threshold_ms` | `5000` | Durata del job (ms) oltre cui = `slow_job` |
114
116
  | `job_queue_latency_threshold_ms` | `60000` | Attesa in coda (enqueue→esecuzione, ms) oltre cui = `job_queue_latency` |
@@ -154,7 +156,9 @@ end
154
156
 
155
157
  Un Rack middleware allega a ogni evento il contesto HTTP della richiesta: **method**, **url** (senza
156
158
  query string) e gli **header dell'allowlist** (`request_header_allowlist`, mai Authorization/Cookie).
157
- Query string e IP solo con `send_pii`. Lo scope è resettato a fine richiesta (niente bleed tra request).
159
+ Query string, IP e **parametri del body** (`request.data`, scrubbati, estratti solo quando scoppia
160
+ l'errore; `capture_request_body` li spegne) solo con `send_pii`. Lo scope è resettato a fine richiesta
161
+ (niente bleed tra request).
158
162
 
159
163
  ### Background job + errori handled (automatico, con Rails)
160
164
 
@@ -311,7 +315,10 @@ end
311
315
  ```
312
316
 
313
317
  Viene inviato un `slow_method` solo se la durata supera `slow_method_threshold_ms`. **Gli argomenti
314
- del metodo non vengono mai inviati** (solo label, durata, file:riga).
318
+ del metodo non vengono inviati** (solo label, durata, file:riga) salvo `capture_method_arguments = true`:
319
+ in quel caso partono con `inspect` troncato, redatti per chiave sensibile (kwargs), credenziali
320
+ `Bearer`/`Basic`/`Token` e `scrub_message_patterns`. Tutto il resto arriva in chiaro: accendilo solo
321
+ su metodi che non ricevono dati personali.
315
322
 
316
323
  ## Performance issues (opt-in)
317
324
 
@@ -359,7 +366,7 @@ sulla pipeline metriche (`/api/v1/projects/:id/metrics`). Funziona con **ActiveJ
359
366
  Due `subtype`:
360
367
 
361
368
  - **`slow_job`** — la durata del `perform` supera `slow_job_threshold_ms` (default 5000 ms).
362
- - **`job_queue_latency`** — l'attesa in coda (dall'enqueue all'inizio dell'esecuzione) supera
369
+ - **`job_queue_latency`** — l'attesa dopo la scadenza effettiva (`max(enqueued_at, scheduled_at)`) supera
363
370
  `job_queue_latency_threshold_ms` (default 60000 ms): coda intasata o worker insufficienti.
364
371
 
365
372
  Ogni metrica porta la **label** (nome della classe del job), la **queue**, l'**adapter**
@@ -380,6 +387,20 @@ CloseYourIt.init do |c|
380
387
  end
381
388
  ```
382
389
 
390
+ Il contesto additivo `contexts.job` usa il contratto `schema_version: 1`: separa durata monotona,
391
+ attesa in coda e ritardo programmato. Un orologio mancante resta `null`; un'attesa negativa viene
392
+ azzerata con `clock_skew: true`. Sidekiq 8 usa timestamp in millisecondi, le versioni precedenti
393
+ secondi: la conversione segue la versione del framework, senza euristiche sulla grandezza.
394
+
395
+ Per osservare anche gli esiti dei tentativi veloci, abilita `c.job_lifecycle_logs = true` (default
396
+ `false`). Emette un log `info` con messaggio fisso `Background job attempt finished` e
397
+ `attributes.contexts.job`, senza argomenti, risultati o header. `retry` non è terminale;
398
+ `discarded` è esplicito; un errore con retry dell'adapter non noto ha `terminal: null`.
399
+ Un `rescue_from` applicativo non identificabile resta `unknown`, non diventa successo.
400
+ ActiveJob dentro Sidekiq è osservato una sola volta quando il subscriber ActiveJob è installato.
401
+ Sampling, disabilitazione o perdita del transport impediscono di considerare questi log un censimento.
402
+ Il raggruppamento backend v1 separa framework, nome, coda e misura; i gruppi legacy restano intatti.
403
+
383
404
  ## Privacy & PII
384
405
 
385
406
  Privacy-by-default (`send_pii = false`). In sintesi:
@@ -391,8 +412,10 @@ Privacy-by-default (`send_pii = false`). In sintesi:
391
412
  (`Authorization: Bearer …`, `Basic …`, `Token …`) sono redatte **di serie**, senza configurazione:
392
413
  chiave e schema restano leggibili, sparisce il valore → `Authorization: Bearer [FILTERED]`. Il resto
393
414
  è redigibile con `scrub_message_patterns` / `before_send`.
394
- - **Mai inviati**: variabili locali dei frame, argomenti dei metodi, IP/cookie/Authorization, token (che
395
- viaggia solo nell'header su HTTPS).
415
+ - **Solo con `send_pii`**: query string, IP, email/username dell'utente e parametri del body
416
+ (`capture_request_body`). Con la privacy spenta non partono, qualunque sia il resto della configurazione.
417
+ - **Mai inviati**: variabili locali dei frame, cookie/Authorization, token (che viaggia solo nell'header
418
+ su HTTPS). Gli argomenti dei metodi solo con `capture_method_arguments` (opt-in, vedi sopra).
396
419
 
397
420
  > Rischio residuo: `exception.value` e i nomi tabella/colonna nello SQL possono contenere dati di
398
421
  > dominio. Usa `before_send`/`scrub_message_patterns` per azzerarli. Dettaglio in [`PDR.md` §9](PDR.md).
@@ -41,7 +41,7 @@ module CloseYourIt
41
41
  :query_time_threshold_ms, :slow_request_threshold_ms, :slow_external_threshold_ms,
42
42
  :capture_external_http, :trap_signals,
43
43
  :monitor_jobs, :slow_job_threshold_ms, :job_queue_latency_threshold_ms,
44
- :jobs_sample_rate, :propagate_trace_context,
44
+ :jobs_sample_rate, :job_lifecycle_logs, :propagate_trace_context,
45
45
  :usage_enabled, :usage_flush_interval, :usage_max_symbols
46
46
  attr_writer :release, :project_root
47
47
  attr_reader :excluded_exceptions, :excluded_log_patterns, :excluded_query_patterns,
@@ -94,7 +94,8 @@ module CloseYourIt
94
94
 
95
95
  # Request body params in the event (`request.data`), extracted LAZILY only when the error
96
96
  # happens, sanitized and scrubbed (denylist + filter_parameters). The backend scrubs again
97
- # defensively. Uploads → placeholder, 64 KB cap.
97
+ # defensively. Uploads → placeholder, 64 KB cap. Gated by send_pii: with it OFF the body
98
+ # is never sent, whatever this flag says (CYRB-28).
98
99
  @capture_request_body = true
99
100
 
100
101
  # Breadcrumbs: history (obfuscated queries, custom events) attached to the error.
@@ -157,6 +158,7 @@ module CloseYourIt
157
158
  # (normal jobs under threshold = no metric) and by sampling. The label is the job class name;
158
159
  # arguments are NEVER sent.
159
160
  @monitor_jobs = true
161
+ @job_lifecycle_logs = false
160
162
  @slow_job_threshold_ms = 5000 # perform duration beyond which = slow_job
161
163
  @job_queue_latency_threshold_ms = 60_000 # enqueue→execution wait beyond which = job_queue_latency
162
164
  @jobs_sample_rate = 1.0 # fraction of over-threshold candidates actually sent
@@ -199,6 +201,13 @@ module CloseYourIt
199
201
  @filter_parameters = Array(list)
200
202
  end
201
203
 
204
+ # Adds the host app's `Rails.application.config.filter_parameters` (strings/symbols/regexps) to the
205
+ # gem list, without duplicates. Rails filter procs are dropped: they mutate values in place, while
206
+ # the Scrubber matches keys only (CYRB-28).
207
+ def merge_filter_parameters(list)
208
+ @filter_parameters |= Array(list).reject { |item| item.respond_to?(:call) }
209
+ end
210
+
202
211
  # Destinations allowed to receive the W3C trace headers. String = exact host (case-insensitive
203
212
  # match), Regexp = pattern (for subdomains/host families). Empty list = none.
204
213
  def trace_propagation_allowlist=(list)
@@ -33,6 +33,9 @@ module CloseYourIt
33
33
  "queue" => @attrs[:queue],
34
34
  "adapter" => @attrs[:adapter],
35
35
  "attempt" => @attrs[:attempt],
36
+ "contexts" => (@attrs[:job_context] && { "job" => @attrs[:job_context].merge(
37
+ "measurement" => (@attrs[:subtype] == "slow_job" ? "duration" : "queue_wait")
38
+ ) }),
36
39
  "sdk" => sdk
37
40
  )
38
41
  end
@@ -42,7 +42,9 @@ module CloseYourIt
42
42
  private
43
43
 
44
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).
45
+ # sensitive key scrubbing (denylist password/token/…). Value = truncated inspect (JSON-safe),
46
+ # passed through the message scrubber (auth schemes + scrub_message_patterns) like exception
47
+ # messages, so a token inside a positional argument never leaves the app (CYRB-28).
46
48
  def arguments
47
49
  return nil unless @configuration.capture_method_arguments
48
50
 
@@ -54,7 +56,7 @@ module CloseYourIt
54
56
  end
55
57
 
56
58
  def safe_value(value)
57
- inspected = value.inspect.to_s
59
+ inspected = @scrubber.scrub_message(value.inspect.to_s)
58
60
  inspected.length > 120 ? "#{inspected[0, 117]}…" : inspected
59
61
  end
60
62
  end
@@ -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
@@ -31,6 +31,9 @@ module CloseYourIt
31
31
  def self.included(base)
32
32
  base.around_perform do |job, block|
33
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
34
37
  end
35
38
 
36
39
  return unless base.respond_to?(:after_discard)
@@ -72,25 +72,9 @@ module CloseYourIt
72
72
  end
73
73
  end
74
74
 
75
- # Measures duration and queue wait of ActiveJob jobs via ActiveSupport notifications:
76
- # `perform_start` gives the wait (now - enqueued_at) as soon as the job starts, `perform` gives
77
- # the execution duration at the end. Beyond threshold → slow_job / job_queue_latency metrics.
78
- # No-op when monitor_jobs is OFF.
75
+ # Observe the completed attempt after ActiveJob has decided retry/discard.
79
76
  initializer "closeyourit.subscribe_active_job_performance" do
80
- jobs = CloseYourIt::Subscribers::JobPerformance.new
81
-
82
- ActiveSupport::Notifications.subscribe("perform_start.active_job") do |*args|
83
- job = ActiveSupport::Notifications::Event.new(*args).payload[:job]
84
- jobs.active_job_started(job) if job
85
- end
86
-
87
- ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
88
- event = ActiveSupport::Notifications::Event.new(*args)
89
- job = event.payload[:job]
90
- # CYSK-29 — jobs also declare they ran: kind `job`, symbol = the class.
91
- CloseYourIt.usage_registry.record("job", job.class.name) if job && CloseYourIt.enabled?
92
- jobs.active_job_performed(job, event.duration) if job
93
- end
77
+ CloseYourIt::Subscribers::JobPerformance.new.install
94
78
  end
95
79
 
96
80
  # Captures HANDLED errors reported via Rails.error.report (Rails 7+).
@@ -116,6 +100,13 @@ module CloseYourIt
116
100
  end
117
101
  end
118
102
 
103
+ # Keys the app declared sensitive via config.filter_parameters are scrubbed by the gem too.
104
+ # `after: :load_config_initializers`: CloseYourIt.init (config/initializers/closeyourit.rb)
105
+ # replaces the configuration, so an earlier merge would be lost (CYRB-28).
106
+ initializer "closeyourit.merge_rails_filter_parameters", after: :load_config_initializers do |app|
107
+ CloseYourIt.configuration.merge_filter_parameters(app.config.filter_parameters)
108
+ end
109
+
119
110
  # Captures Sidekiq job errors + measures duration/wait via server middleware (only when Sidekiq
120
111
  # is present). The middleware is effectively a no-op when monitor_jobs is OFF (the decision
121
112
  # lives in JobPerformance#record).
@@ -139,8 +139,10 @@ module CloseYourIt
139
139
  data ? @request.merge("data" => data) : @request
140
140
  end
141
141
 
142
+ # Body params are user input (PII): sent only with send_pii AND capture_request_body (CYRB-28).
142
143
  def request_body_data
143
- return nil unless CloseYourIt.configuration.capture_request_body
144
+ config = CloseYourIt.configuration
145
+ return nil unless config.send_pii && config.capture_request_body
144
146
  return nil if @rack_env.nil?
145
147
 
146
148
  Rails::RequestBody.extract(@rack_env)
@@ -69,8 +69,9 @@ module CloseYourIt
69
69
  # credential: here the prose guard is needed, the word "bearer" shows up in messages.
70
70
  AUTH_CREDENTIAL = /(\b#{AUTH_SCHEME}[ \t]+)#{NOT_PROSE}#{CREDENTIAL_TOKEN}/
71
71
 
72
+ DOLLAR_LITERAL = /\$([A-Za-z_][A-Za-z0-9_]*|)\$.*?(?:\$\1\$|\z)/m
72
73
  STRING_LITERAL = /'(?:[^']|'')*'/
73
- NUMERIC_LITERAL = /\b\d+(?:\.\d+)?\b/
74
+ NUMERIC_LITERAL = /(?<![\w$])\d+(?:\.\d+)?\b/
74
75
 
75
76
  def initialize(configuration)
76
77
  @configuration = configuration
@@ -94,7 +95,7 @@ module CloseYourIt
94
95
  def obfuscate_sql(sql)
95
96
  return sql if sql.nil? || !@configuration.obfuscate_sql
96
97
 
97
- sql.to_s.gsub(STRING_LITERAL, "?").gsub(NUMERIC_LITERAL, "?")
98
+ sql.to_s.gsub(DOLLAR_LITERAL, "?").gsub(STRING_LITERAL, "?").gsub(NUMERIC_LITERAL, "?")
98
99
  end
99
100
 
100
101
  # Built-in rule (authentication schemes) BEFORE the user patterns: the two protections add up,
@@ -16,27 +16,50 @@ module CloseYourIt
16
16
  end
17
17
 
18
18
  def call(_worker, job, queue)
19
+ return yield if active_job_owned?(job)
20
+
19
21
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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.
22
- latency = Subscribers::JobPerformance.latency_ms(job["enqueued_at"], now: Time.now.utc)
23
- yield
22
+ context = observe(job, queue)
23
+ value = yield
24
+ succeeded = true
25
+ value
24
26
  ensure
25
- emit(job, queue, started, latency)
27
+ emit(job, queue, started, context, succeeded) if started
26
28
  end
27
29
 
28
30
  private
29
31
 
30
- def emit(job, queue, started, latency)
32
+ def active_job_owned?(job)
33
+ %w[ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper Sidekiq::ActiveJob::Wrapper].include?(job["class"]) &&
34
+ Subscribers::JobPerformance.active_job_installed?
35
+ end
36
+
37
+ def observe(job, queue)
38
+ # Sidekiq 8 changed the timestamp wire unit; do not infer it from magnitude.
39
+ version = ::Sidekiq::VERSION if defined?(::Sidekiq::VERSION)
40
+ enqueued = JobContext.sidekiq_time(job["enqueued_at"], version: version)
41
+ JobContext.build(framework: "sidekiq", name: job["wrapped"] || job["class"],
42
+ queue: queue || job["queue"], job_id: job["jid"], attempt: attempt(job), retry_count: job["retry_count"],
43
+ enqueued_at: enqueued, started_at: Time.now.utc)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def emit(job, queue, started, context, succeeded)
31
49
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
50
+ if context
51
+ context = context.merge("duration_ms" => duration_ms, "attempt_outcome" => succeeded ? "success" : "error",
52
+ "terminal" => succeeded ? true : nil)
53
+ JobContext.log(context, CloseYourIt.configuration)
54
+ end
32
55
  subscriber.record(
33
56
  job_class: job["wrapped"] || job["class"],
34
57
  queue: queue || job["queue"],
35
58
  adapter: "sidekiq",
36
59
  duration_ms: duration_ms,
37
- queue_latency_ms: latency,
60
+ queue_latency_ms: context && context["queue_wait_ms"],
38
61
  attempt: attempt(job),
39
- trace_id: job["jid"]
62
+ trace_id: job["jid"], job_context: context
40
63
  )
41
64
  rescue StandardError => e
42
65
  CloseYourIt.internal_logger.error("CloseYourIt job metrics: #{e.class}: #{e.message}")
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "../events/job_metric_event"
5
+ require_relative "../job_context"
5
6
 
6
7
  module CloseYourIt
7
8
  module Subscribers
@@ -12,10 +13,43 @@ module CloseYourIt
12
13
  # wiring to ActiveSupport::Notifications and the Sidekiq middleware lives elsewhere (Railtie /
13
14
  # JobMetricsMiddleware). Honors the `monitor_jobs` master switch, the thresholds and `jobs_sample_rate`.
14
15
  class JobPerformance
16
+ REGISTRY_LOCK = Mutex.new
17
+ ACTIVE_JOB_SUBSCRIBERS = []
18
+
19
+ def self.active_job_installed?
20
+ REGISTRY_LOCK.synchronize { !ACTIVE_JOB_SUBSCRIBERS.empty? }
21
+ end
15
22
  def initialize(configuration = nil)
16
23
  @configuration = configuration
17
24
  end
18
25
 
26
+ def install
27
+ REGISTRY_LOCK.synchronize do
28
+ return self if @subscriptions || !ACTIVE_JOB_SUBSCRIBERS.empty?
29
+
30
+ @subscriptions = []
31
+ { "enqueue_retry" => :retry, "retry_stopped" => :exhausted, "discard" => :discarded }.each do |name, outcome|
32
+ subscribe(name) { |event| event.payload[:job]&.instance_variable_set(:@__closeyourit_job_outcome, outcome) }
33
+ end
34
+ subscribe("perform_start") { |event| active_job_started(event.payload[:job]) if event.payload[:job] }
35
+ subscribe("perform") do |event|
36
+ job = event.payload[:job]
37
+ if job
38
+ CloseYourIt.usage_registry.record("job", job.class.name) if CloseYourIt.enabled?
39
+ active_job_performed(job, event.duration, payload: event.payload)
40
+ end
41
+ end
42
+ ACTIVE_JOB_SUBSCRIBERS << self
43
+ self
44
+ end
45
+ end
46
+
47
+ def uninstall
48
+ Array(@subscriptions).each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
49
+ @subscriptions = nil
50
+ REGISTRY_LOCK.synchronize { ACTIVE_JOB_SUBSCRIBERS.delete(self) }
51
+ end
52
+
19
53
  # Single decision point: from the measured values (duration and/or wait) it builds 0..2 metrics,
20
54
  # applies the thresholds (strict `>`, so X makes no noise and X+1 does) and sampling, then sends
21
55
  # them fire-and-forget. `duration_ms` and `queue_latency_ms` are optional: ActiveJob provides them
@@ -23,11 +57,11 @@ module CloseYourIt
23
57
  # Sampling is applied ONLY to candidates already beyond the threshold (normal jobs neither
24
58
  # consume nor generate anything).
25
59
  def record(job_class:, queue: nil, adapter: nil, duration_ms: nil, queue_latency_ms: nil,
26
- attempt: nil, trace_id: nil)
60
+ attempt: nil, trace_id: nil, job_context: nil)
27
61
  config = configuration
28
62
  return unless config.monitor_jobs
29
63
 
30
- common = { job_class: job_class, queue: queue, adapter: adapter, attempt: attempt, trace_id: trace_id }
64
+ common = { job_class: job_class, queue: queue, adapter: adapter, attempt: attempt, trace_id: trace_id, job_context: job_context }
31
65
  events = []
32
66
  events << build(config, "slow_job", duration_ms, common) if slow?(config, duration_ms)
33
67
  events << build(config, "job_queue_latency", queue_latency_ms, common) if waited?(config, queue_latency_ms)
@@ -41,26 +75,51 @@ module CloseYourIt
41
75
  # `perform_start.active_job` hook: the queue wait is known as soon as the job starts (now - the
42
76
  # moment it was due to run).
43
77
  def active_job_started(job, now: Time.now.utc)
78
+ context = JobContext.build(framework: "active_job", name: job.class.name,
79
+ queue: (job.queue_name if job.respond_to?(:queue_name)), job_id: (job.job_id if job.respond_to?(:job_id)),
80
+ attempt: (job.executions if job.respond_to?(:executions)), enqueued_at: enqueued_at(job),
81
+ scheduled_at: (job.scheduled_at if job.respond_to?(:scheduled_at)), started_at: now)
82
+ job.instance_variable_set(:@__closeyourit_job_context, context)
44
83
  record(
45
84
  job_class: job.class.name,
46
85
  queue: (job.queue_name if job.respond_to?(:queue_name)),
47
86
  adapter: "active_job",
48
- queue_latency_ms: self.class.latency_ms(due_at(job), now: now),
87
+ queue_latency_ms: context["queue_wait_ms"],
49
88
  attempt: (job.executions if job.respond_to?(:executions)),
50
- trace_id: (job.job_id if job.respond_to?(:job_id))
89
+ trace_id: (job.job_id if job.respond_to?(:job_id)), job_context: context
51
90
  )
52
91
  end
53
92
 
54
93
  # `perform.active_job` hook: at the end of execution the duration is `event.duration` (ms).
55
- def active_job_performed(job, duration_ms)
94
+ def active_job_performed(job, duration_ms, payload: {})
95
+ context = job.instance_variable_get(:@__closeyourit_job_context)
96
+ if context
97
+ outcome, terminal = active_job_outcome(job, payload)
98
+ context = context.merge("duration_ms" => duration_ms, "attempt_outcome" => outcome, "terminal" => terminal)
99
+ JobContext.log(context, configuration)
100
+ end
56
101
  record(
57
102
  job_class: job.class.name,
58
103
  queue: (job.queue_name if job.respond_to?(:queue_name)),
59
104
  adapter: "active_job",
60
105
  duration_ms: duration_ms,
61
106
  attempt: (job.executions if job.respond_to?(:executions)),
62
- trace_id: (job.job_id if job.respond_to?(:job_id))
107
+ trace_id: (job.job_id if job.respond_to?(:job_id)), job_context: context
63
108
  )
109
+ ensure
110
+ job.remove_instance_variable(:@__closeyourit_job_context) if job.instance_variable_defined?(:@__closeyourit_job_context)
111
+ job.remove_instance_variable(:@__closeyourit_job_outcome) if job.instance_variable_defined?(:@__closeyourit_job_outcome)
112
+ end
113
+
114
+ def active_job_outcome(job, payload)
115
+ outcome = job.instance_variable_get(:@__closeyourit_job_outcome)
116
+ return [ "error", nil ] if payload[:exception_object] || payload[:exception]
117
+ return [ "retry", false ] if outcome == :retry
118
+ return [ "discarded", true ] if outcome == :discarded
119
+ return [ "error", nil ] if outcome == :exhausted
120
+ return [ "unknown", nil ] if outcome == :raised || payload[:aborted]
121
+
122
+ [ "success", true ]
64
123
  end
65
124
 
66
125
  # Normalizes `enqueued_at` (Time, Numeric epoch in seconds like Sidekiq, or ISO8601 String) into
@@ -91,6 +150,14 @@ module CloseYourIt
91
150
 
92
151
  private
93
152
 
153
+ def subscribe(name, &callback)
154
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("#{name}.active_job") do |*args|
155
+ callback.call(ActiveSupport::Notifications::Event.new(*args))
156
+ rescue StandardError
157
+ CloseYourIt.internal_logger.warn("CloseYourIt job observation failed")
158
+ end
159
+ end
160
+
94
161
  def enqueued_at(job)
95
162
  job.enqueued_at if job.respond_to?(:enqueued_at)
96
163
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.10.2"
4
+ VERSION = "0.12.1"
5
5
  end
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.10.2
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessio Bussolari
@@ -62,6 +62,7 @@ files:
62
62
  - lib/closeyourit/events/slow_method_event.rb
63
63
  - lib/closeyourit/events/slow_query_event.rb
64
64
  - lib/closeyourit/instrumenter.rb
65
+ - lib/closeyourit/job_context.rb
65
66
  - lib/closeyourit/line_cache.rb
66
67
  - lib/closeyourit/log_buffer.rb
67
68
  - lib/closeyourit/log_device.rb
@@ -89,12 +90,12 @@ files:
89
90
  - lib/closeyourit/transport.rb
90
91
  - lib/closeyourit/usage_registry.rb
91
92
  - lib/closeyourit/version.rb
92
- homepage: https://github.com/bussolabs/closeyourit-ruby
93
+ homepage: https://github.com/bussolabs/closeyourit-ruby-community
93
94
  licenses:
94
95
  - MIT
95
96
  metadata:
96
- homepage_uri: https://github.com/bussolabs/closeyourit-ruby
97
- source_code_uri: https://github.com/bussolabs/closeyourit-ruby
97
+ homepage_uri: https://github.com/bussolabs/closeyourit-ruby-community
98
+ source_code_uri: https://github.com/bussolabs/closeyourit-ruby-community
98
99
  rdoc_options: []
99
100
  require_paths:
100
101
  - lib