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 +4 -4
- data/README.md +33 -10
- data/lib/closeyourit/configuration.rb +11 -2
- data/lib/closeyourit/events/job_metric_event.rb +3 -0
- data/lib/closeyourit/events/slow_method_event.rb +4 -2
- data/lib/closeyourit/job_context.rb +61 -0
- data/lib/closeyourit/rails/active_job_extension.rb +3 -0
- data/lib/closeyourit/rails/railtie.rb +9 -18
- data/lib/closeyourit/scope.rb +3 -1
- data/lib/closeyourit/scrubber.rb +3 -2
- data/lib/closeyourit/sidekiq/job_metrics_middleware.rb +31 -8
- data/lib/closeyourit/subscribers/job_performance.rb +73 -6
- data/lib/closeyourit/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8e0fc4eed9289e8fbb3ac2b93e6bcf71c950200b24483be93ca9b03cbb5edf5
|
|
4
|
+
data.tar.gz: bec0fc92de3a19b85caa500e41953726a1693ca8f757cd98022b8b5287e83f76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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://
|
|
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://
|
|
66
|
-
>
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
395
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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).
|
data/lib/closeyourit/scope.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/closeyourit/scrubber.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
context = observe(job, queue)
|
|
23
|
+
value = yield
|
|
24
|
+
succeeded = true
|
|
25
|
+
value
|
|
24
26
|
ensure
|
|
25
|
-
emit(job, queue, started,
|
|
27
|
+
emit(job, queue, started, context, succeeded) if started
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
private
|
|
29
31
|
|
|
30
|
-
def
|
|
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:
|
|
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:
|
|
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
|
data/lib/closeyourit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: closeyourit-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.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
|