closeyourit-ruby 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/README.md +102 -1
- data/lib/closeyourit/client.rb +9 -1
- data/lib/closeyourit/configuration.rb +35 -3
- data/lib/closeyourit/events/error_event.rb +18 -1
- data/lib/closeyourit/events/log_event.rb +30 -12
- data/lib/closeyourit/events/message_event.rb +7 -2
- data/lib/closeyourit/log_buffer.rb +5 -0
- data/lib/closeyourit/log_device.rb +31 -2
- data/lib/closeyourit/rails/active_job_extension.rb +4 -0
- data/lib/closeyourit/rails/log_broadcast.rb +6 -4
- data/lib/closeyourit/rails/railtie.rb +5 -2
- data/lib/closeyourit/rails/request_context.rb +16 -0
- data/lib/closeyourit/scope.rb +12 -2
- data/lib/closeyourit/scrubber.rb +11 -4
- data/lib/closeyourit/subscribers/slow_query.rb +6 -0
- data/lib/closeyourit/transport.rb +29 -5
- data/lib/closeyourit/version.rb +1 -1
- data/lib/closeyourit-ruby.rb +60 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c80dd7130394211bc1c0ebbf7a13bbe9906951b667734380bdf38aac53f194ae
|
|
4
|
+
data.tar.gz: 9adde552df65cee4cd32d47161bdd84fbe5a69c06698b9fc5c9cfd5a1a2a1088
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f7cc16b574bfc124e9f9f49037a7045dc30a7125da890f78ae6c7c36712a418da61dbd2170a12a8d34be35ea5fc8fb96568dc58e1e573677fe67afb902c071e
|
|
7
|
+
data.tar.gz: aa9871930fa03617485c993e2f84db54aeb23ecd092c8a2a15a463e9dcda75c710d3cadef95f14081bbe3844cca50f4215ef0c5b40cb149e205ff1d6e701a575
|
data/README.md
CHANGED
|
@@ -72,6 +72,7 @@ end
|
|
|
72
72
|
| `before_send` | `nil` | `->(payload) { ... }` — scrub finale; ritorna payload o `nil` per scartare |
|
|
73
73
|
| `sample_rate` | `1.0` | Frazione di errori/messaggi inviata (`1.0` tutto, `0.0` niente) |
|
|
74
74
|
| `async_threads` | `cpu/2` | Thread di invio; `0` = sincrono (test) |
|
|
75
|
+
| `trap_signals` | `false` | Intercetta `SIGTERM` per garantire il flush di fine-vita (deploy/Kamal); opt-in perché sovrascrive un eventuale handler `TERM` dell'app ospite (vedi [Flush di fine-vita](#flush-di-fine-vita)) |
|
|
75
76
|
| `slow_query_threshold_ms` | `100` | Soglia query lente |
|
|
76
77
|
| `slow_method_threshold_ms` | `200` | Soglia metodi lenti |
|
|
77
78
|
| `capture_request` | `true` | Cattura il contesto HTTP della richiesta (method/url/header allowlist) |
|
|
@@ -83,7 +84,21 @@ end
|
|
|
83
84
|
| `send_pii` | `false` | Master switch PII |
|
|
84
85
|
| `obfuscate_sql` | `true` | Maschera i literal nello SQL |
|
|
85
86
|
| `filter_parameters` | `[]` | Chiavi extra da redarre (mergiate con quelle di Rails) |
|
|
86
|
-
| `scrub_message_patterns` | `[]` | Regexp da redarre
|
|
87
|
+
| `scrub_message_patterns` | `[]` | Regexp da redarre da messaggi d'eccezione **e da `message` dei log** |
|
|
88
|
+
| `logs_enabled` | `true` | Master switch dello stream di log strutturato |
|
|
89
|
+
| `logs_min_level` | `:info` | Soglia minima: i log sotto (es. `debug`) non vengono inviati |
|
|
90
|
+
| `logs_sample_rate` | `1.0` | Frazione di log inviata (`1.0` tutto, `0.0` niente) |
|
|
91
|
+
| `logs_batch_size` | `50` | Log accumulati nel buffer prima di un flush a batch verso `/logs` |
|
|
92
|
+
| `logs_flush_interval` | `5` | Secondi tra i flush periodici del buffer log |
|
|
93
|
+
| `capture_rails_logs` | `false` | Inoltra anche `Rails.logger` allo stream — attenzione al volume (soglia `capture_rails_logs_min_level`) |
|
|
94
|
+
| `capture_rails_logs_min_level` | `:warn` | Soglia **dedicata** del broadcast `Rails.logger` (distinta da `logs_min_level`): default conservativo per non inondare lo stream col rumore `info` del framework |
|
|
95
|
+
| `detect_performance_issues` | `false` | Master switch dei **verdetti** performance (N+1, query count, request/HTTP esterne lente). Opt-in: profila ogni query → overhead (vedi [Performance issues](#performance-issues-opt-in)) |
|
|
96
|
+
| `n_plus_one_threshold` | `10` | Ripetizioni dello stesso `[fingerprint, call-site]` in una richiesta oltre cui = `n_plus_one` |
|
|
97
|
+
| `query_count_threshold` | `100` | Query totali in una richiesta oltre cui = `high_query_count` |
|
|
98
|
+
| `query_time_threshold_ms` | `500` | Tempo DB totale per richiesta (ms) oltre cui = `high_query_count` |
|
|
99
|
+
| `slow_request_threshold_ms` | `1000` | Durata totale della richiesta (ms) oltre cui = `slow_request` |
|
|
100
|
+
| `slow_external_threshold_ms` | `1000` | Durata di una singola HTTP esterna (ms) oltre cui = `slow_external_http` |
|
|
101
|
+
| `capture_external_http` | `true` | Strumenta `Net::HTTP` per rilevare le HTTP esterne (effettivo solo con `detect_performance_issues`) |
|
|
87
102
|
|
|
88
103
|
## Cosa cattura
|
|
89
104
|
|
|
@@ -116,6 +131,55 @@ Query string e IP solo con `send_pii`. Lo scope è resettato a fine richiesta (n
|
|
|
116
131
|
`mechanism.handled = true` e il `level` mappato dalla severity. La dedup garantisce un solo invio anche
|
|
117
132
|
se la stessa eccezione passa da più punti (Rack + job + reporter).
|
|
118
133
|
|
|
134
|
+
### Log strutturati (manuale/automatico) → stream log
|
|
135
|
+
|
|
136
|
+
Stream di log strutturati (`level`/`message`/`attributes`/`logger`) separato dagli errori, correlato via
|
|
137
|
+
`trace_id` (stesso della richiesta). Bufferizzato e spedito a batch a `/logs`. `message` **e**
|
|
138
|
+
`attributes` passano dallo scrubber (denylist chiavi + `scrub_message_patterns`) come gli errori.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# API esplicita — il keyword `logger:` è la sorgente del log; ogni altra keyword è un attributo
|
|
142
|
+
CloseYourIt.log(:info, "ordine creato", order_id: order.id, amount: order.amount)
|
|
143
|
+
CloseYourIt.log(:warning, "retry pagamento", attempt: 2)
|
|
144
|
+
|
|
145
|
+
# Interfaccia ::Logger-like (level via metodo)
|
|
146
|
+
CloseYourIt.logger.warn("cache miss", key: cache_key)
|
|
147
|
+
CloseYourIt.logger.error("job fallito", job: self.class.name)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
La **sorgente** del log (campo `logger`) si imposta con `.named` — child logger immutabile, stesso
|
|
151
|
+
idioma di dart/js. Il logger radice resta invariato:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
payments = CloseYourIt.logger.named("payments") # child con sorgente "payments"
|
|
155
|
+
payments.warn("retry", attempt: 3) # logger="payments", attributes={attempt: 3}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Sull'interfaccia `.logger` una chiave `logger:` è **sempre un attributo dati** (non viene hijackata
|
|
159
|
+
come sorgente): `CloseYourIt.logger.warn("disco pieno", logger: "/dev/sda1")` mette `logger` negli
|
|
160
|
+
`attributes`. Sorgente (`.named`) e attributo `logger` possono coesistere senza collidere.
|
|
161
|
+
|
|
162
|
+
Ogni log sotto `logs_min_level` (default `:info`) è **scartato** prima di essere costruito o spedito —
|
|
163
|
+
`CloseYourIt.log` e `CloseYourIt.logger.*` usano la mappa di livelli (`debug` < `info` < `warning` <
|
|
164
|
+
`error` < `fatal`), identica agli SDK dart/js. Un `CloseYourIt.logger.debug(...)` in un loop caldo con
|
|
165
|
+
la soglia di default non produce alcun invio.
|
|
166
|
+
|
|
167
|
+
Con Rails, `capture_rails_logs = true` inoltra anche `Rails.logger` allo stream, ma con una soglia
|
|
168
|
+
**dedicata** `capture_rails_logs_min_level` (default `:warn`, distinta da `logs_min_level`): in
|
|
169
|
+
produzione ad alto traffico Rails logga a `:info` ogni richiesta (`Started GET`, `Rendered`, ...) e un
|
|
170
|
+
default a `:info` inonderebbe lo stream col rumore del framework — la soglia conservativa lo evita.
|
|
171
|
+
Abbassala esplicitamente (es. `config.capture_rails_logs_min_level = :info`) solo se vuoi anche gli
|
|
172
|
+
`info` del framework. I log dei background job ereditano il `trace_id = job_id`, così log ed errore
|
|
173
|
+
dello stesso job si correlano.
|
|
174
|
+
|
|
175
|
+
#### Flush di fine-vita
|
|
176
|
+
|
|
177
|
+
Il buffer log viene svuotato automaticamente allo **shutdown del processo**: `CloseYourIt.init` registra
|
|
178
|
+
un `at_exit` che drena anche il worker asincrono (attende gli invii in volo, non solo il flush del
|
|
179
|
+
buffer), così i log sotto-batch di rake/CLI non vanno persi all'uscita. `SIGTERM` (deploy/Kamal)
|
|
180
|
+
termina il processo **senza** eseguire gli `at_exit`: imposta `trap_signals = true` per intercettarlo e
|
|
181
|
+
convertirlo in un exit pulito, oppure chiama `CloseYourIt.shutdown` esplicitamente prima di terminare.
|
|
182
|
+
|
|
119
183
|
### Contesto, breadcrumbs e messaggi (manuale)
|
|
120
184
|
|
|
121
185
|
```ruby
|
|
@@ -156,6 +220,43 @@ end
|
|
|
156
220
|
Viene inviato un `slow_method` solo se la durata supera `slow_method_threshold_ms`. **Gli argomenti
|
|
157
221
|
del metodo non vengono mai inviati** (solo label, durata, file:riga).
|
|
158
222
|
|
|
223
|
+
## Performance issues (opt-in)
|
|
224
|
+
|
|
225
|
+
`slow_query` e `slow_method` sono metriche **puntuali** (una query/un metodo lenti). Oltre a queste,
|
|
226
|
+
con **`detect_performance_issues = true`** la gemma profila **ogni query della richiesta**
|
|
227
|
+
(fingerprint + call-site) e, a fine richiesta, emette 0..N **verdetti** `performance_issue` sulla
|
|
228
|
+
pipeline metriche (`/api/v1/projects/:id/metrics`). Un verdetto non è una singola query lenta: è un
|
|
229
|
+
**pattern dell'intera richiesta**, aggregato e correlato a log/errori via `trace_id`. I quattro
|
|
230
|
+
`subtype`:
|
|
231
|
+
|
|
232
|
+
- **`n_plus_one`** — lo stesso `[fingerprint SQL, call-site]` eseguito più di `n_plus_one_threshold`
|
|
233
|
+
volte (default 10) nella stessa richiesta: il classico N+1. Un verdetto per gruppo, con lo SQL già
|
|
234
|
+
offuscato e il `source` (`file:riga`) che lo ha originato.
|
|
235
|
+
- **`high_query_count`** — troppe query in una richiesta: numero totale oltre `query_count_threshold`
|
|
236
|
+
(default 100) **oppure** tempo DB totale oltre `query_time_threshold_ms` (default 500 ms).
|
|
237
|
+
- **`slow_request`** — durata totale della richiesta oltre `slow_request_threshold_ms` (default
|
|
238
|
+
1000 ms), con `route`, `query_count` e tempo DB come contesto.
|
|
239
|
+
- **`slow_external_http`** — una singola chiamata HTTP esterna (`Net::HTTP`) oltre
|
|
240
|
+
`slow_external_threshold_ms` (default 1000 ms). Richiede `capture_external_http` (default `true`, che
|
|
241
|
+
strumenta `Net::HTTP` **solo** quando la detection è attiva); host e path templatizzato, mai la query string.
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
CloseYourIt.init do |c|
|
|
245
|
+
# …endpoint_url / token / project_id…
|
|
246
|
+
c.detect_performance_issues = true
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Con Rails il wiring è **automatico** (il Railtie si iscrive a `process_action.action_controller`):
|
|
251
|
+
attivato il master switch, non serve altro codice. Le soglie sopra sono conservative (poco rumore) e
|
|
252
|
+
tutte ritoccabili.
|
|
253
|
+
|
|
254
|
+
> **Overhead — perché è opt-in (default OFF).** Per aggregare i verdetti la detection profila **ogni**
|
|
255
|
+
> query della richiesta (fingerprint + call-site): un costo non trascurabile, da attivare
|
|
256
|
+
> consapevolmente per-app. `slow_query`/`slow_method` restano attive e **indipendenti** da questo
|
|
257
|
+
> switch. (Pendant server-side dei verdetti `performance_issue` di closeyourit-js: qui i `subtype`
|
|
258
|
+
> sono quelli del backend Rails — N+1, query count, request/HTTP esterne lente.)
|
|
259
|
+
|
|
159
260
|
## Privacy & PII
|
|
160
261
|
|
|
161
262
|
Privacy-by-default (`send_pii = false`). In sintesi:
|
data/lib/closeyourit/client.rb
CHANGED
|
@@ -27,6 +27,13 @@ module CloseYourIt
|
|
|
27
27
|
accepted = @worker.perform { @transport.send_event(payload, path: path) }
|
|
28
28
|
CloseYourIt.stats.increment(:enqueued) if accepted
|
|
29
29
|
payload
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
# La telemetria non deve MAI propagare nel path dell'app ospite: capture_event è invocato dal
|
|
32
|
+
# subscriber sql.active_record, che gira nel thread della query. to_h (scrubber su bind
|
|
33
|
+
# malformato) e before_send sono valutati qui in modo sincrono → se sollevano, assorbiamo,
|
|
34
|
+
# logghiamo e scartiamo l'evento invece di disturbare la query ospite. Vedi CYRB-2.
|
|
35
|
+
CloseYourIt.internal_logger.error("CloseYourIt client: #{e.class}: #{e.message}")
|
|
36
|
+
nil
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
# Invia un batch di log come ARRAY a /logs (l'endpoint accetta singolo o array). before_send è
|
|
@@ -43,7 +50,8 @@ module CloseYourIt
|
|
|
43
50
|
|
|
44
51
|
path = events.first.ingest_path(@configuration.project_id)
|
|
45
52
|
payloads.each_slice(LOGS_MAX_BATCH) do |chunk|
|
|
46
|
-
@worker.perform { @transport.send_event(chunk, path: path) }
|
|
53
|
+
accepted = @worker.perform { @transport.send_event(chunk, path: path) }
|
|
54
|
+
CloseYourIt.stats.increment(:enqueued) if accepted
|
|
47
55
|
end
|
|
48
56
|
payloads
|
|
49
57
|
end
|
|
@@ -25,11 +25,11 @@ module CloseYourIt
|
|
|
25
25
|
:breadcrumbs_enabled, :max_breadcrumbs, :sample_rate,
|
|
26
26
|
:capture_handled_errors, :report_active_job_errors,
|
|
27
27
|
:logs_enabled, :logs_sample_rate, :logs_batch_size, :logs_flush_interval,
|
|
28
|
-
:capture_rails_logs, :logs_min_level,
|
|
28
|
+
:capture_rails_logs, :logs_min_level, :capture_rails_logs_min_level,
|
|
29
29
|
:detect_performance_issues, :n_plus_one_threshold, :query_count_threshold,
|
|
30
30
|
:query_time_threshold_ms, :slow_request_threshold_ms, :slow_external_threshold_ms,
|
|
31
|
-
:capture_external_http
|
|
32
|
-
attr_writer :release
|
|
31
|
+
:capture_external_http, :trap_signals
|
|
32
|
+
attr_writer :release, :project_root
|
|
33
33
|
attr_reader :excluded_exceptions, :filter_parameters, :scrub_message_patterns
|
|
34
34
|
|
|
35
35
|
def initialize
|
|
@@ -45,6 +45,11 @@ module CloseYourIt
|
|
|
45
45
|
@async_threads = default_threads
|
|
46
46
|
@background_worker_max_queue = 30
|
|
47
47
|
|
|
48
|
+
# Intercetta SIGTERM per garantire il flush di fine-vita (deploy/Kamal): SIGTERM di default
|
|
49
|
+
# termina il processo SENZA eseguire gli at_exit. OPT-IN perché sovrascrive un eventuale handler
|
|
50
|
+
# TERM dell'app ospite. Vedi CloseYourIt.shutdown / register_shutdown_flush (CYRB-5).
|
|
51
|
+
@trap_signals = false
|
|
52
|
+
|
|
48
53
|
@slow_query_threshold_ms = 100
|
|
49
54
|
@slow_method_threshold_ms = 200
|
|
50
55
|
|
|
@@ -87,6 +92,12 @@ module CloseYourIt
|
|
|
87
92
|
# Broadcast opt-in di Rails.logger → CloseYourIt.log (default OFF; spedisce solo ≥ soglia).
|
|
88
93
|
@capture_rails_logs = false
|
|
89
94
|
@logs_min_level = :info
|
|
95
|
+
# Soglia DEDICATA del broadcast Rails.logger, distinta da logs_min_level (che governa
|
|
96
|
+
# CloseYourIt.log/.logger, dove è il dev a scegliere cosa loggare). Default :warn — conservativo:
|
|
97
|
+
# senza, in produzione ad alto traffico OGNI riga info del framework (Started GET, Rendered, ...)
|
|
98
|
+
# inonderebbe lo stream con decine di migliaia di log-entry/min (CYRB-7). Chi vuole anche gli info
|
|
99
|
+
# del framework la abbassa esplicitamente (es. :info).
|
|
100
|
+
@capture_rails_logs_min_level = :warn
|
|
90
101
|
|
|
91
102
|
# Performance issue detection (verdetti aggregati: N+1, slow request, HTTP esterne lente).
|
|
92
103
|
# OPT-IN, default OFF: profila OGNI query della richiesta → overhead non trascurabile, va attivato
|
|
@@ -99,6 +110,10 @@ module CloseYourIt
|
|
|
99
110
|
@slow_external_threshold_ms = 1000 # singola chiamata HTTP esterna
|
|
100
111
|
@capture_external_http = true # strumenta Net::HTTP (solo se detect_performance_issues)
|
|
101
112
|
|
|
113
|
+
# Radice del progetto: base per il filename relativo dei frame (culprit cross-SDK). Lazy:
|
|
114
|
+
# auto-rilevata da Rails.root o Dir.pwd al primo accesso se non impostata esplicitamente.
|
|
115
|
+
@project_root = nil
|
|
116
|
+
|
|
102
117
|
@filter_parameters = []
|
|
103
118
|
@scrub_message_patterns = []
|
|
104
119
|
end
|
|
@@ -145,6 +160,23 @@ module CloseYourIt
|
|
|
145
160
|
@release = detect_release
|
|
146
161
|
end
|
|
147
162
|
|
|
163
|
+
# Radice del progetto effettiva: quella impostata, altrimenti auto-rilevata (Rails.root o Dir.pwd).
|
|
164
|
+
# Usata per rendere `frame.filename` relativo (culprit confrontabile cross-SDK — CYRB-4).
|
|
165
|
+
def project_root
|
|
166
|
+
return @project_root unless @project_root.nil?
|
|
167
|
+
|
|
168
|
+
@project_root = detect_project_root
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Rails.root quando l'app gira sotto Rails, altrimenti la working directory. Mai solleva.
|
|
172
|
+
def detect_project_root
|
|
173
|
+
return ::Rails.root.to_s if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
174
|
+
|
|
175
|
+
Dir.pwd
|
|
176
|
+
rescue StandardError
|
|
177
|
+
Dir.pwd
|
|
178
|
+
end
|
|
179
|
+
|
|
148
180
|
# Auto-rilevamento release dalle env di deploy/CI o dal git short SHA. Mai solleva.
|
|
149
181
|
def detect_release
|
|
150
182
|
ENV["KAMAL_VERSION"] ||
|
|
@@ -75,13 +75,15 @@ module CloseYourIt
|
|
|
75
75
|
|
|
76
76
|
# Sentry-style: frame più recente per ultimo; chiavi filename/function/lineno/in_app/abs_path
|
|
77
77
|
# + snippet di sorgente (pre_context/context_line/post_context) quando il file è leggibile.
|
|
78
|
+
# `filename` è relativo/basename (culprit confrontabile cross-SDK — CYRB-4); `abs_path` è il
|
|
79
|
+
# path assoluto per il dettaglio e per leggere il sorgente delle context lines.
|
|
78
80
|
def frames(locations)
|
|
79
81
|
return [] if locations.nil?
|
|
80
82
|
|
|
81
83
|
context_lines = @configuration.context_lines.to_i
|
|
82
84
|
locations.reverse.map do |loc|
|
|
83
85
|
frame = {
|
|
84
|
-
"filename" => loc.path,
|
|
86
|
+
"filename" => relative_filename(loc.path),
|
|
85
87
|
"abs_path" => loc.path,
|
|
86
88
|
"function" => loc.label,
|
|
87
89
|
"lineno" => loc.lineno,
|
|
@@ -92,6 +94,21 @@ module CloseYourIt
|
|
|
92
94
|
end
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
# Path da mostrare (Sentry `filename`): relativo alla project_root se il file è dentro l'app
|
|
98
|
+
# (es. "app/models/user.rb"), altrimenti il basename (gem/stdlib/path esterni → "base.rb").
|
|
99
|
+
# Allinea Ruby agli altri SDK (Dart/JS mandano basename/relativo), così il culprit
|
|
100
|
+
# "filename in function" è confrontabile a colpo d'occhio su progetti poliglotti. Vedi CYRB-4.
|
|
101
|
+
def relative_filename(path)
|
|
102
|
+
return path if path.nil?
|
|
103
|
+
|
|
104
|
+
root = @configuration.project_root.to_s
|
|
105
|
+
prefix = root.empty? ? nil : File.join(root, "") # root con separatore finale, portabile
|
|
106
|
+
return File.basename(path) unless prefix && path.start_with?(prefix)
|
|
107
|
+
|
|
108
|
+
relative = path[prefix.length..]
|
|
109
|
+
relative.nil? || relative.empty? ? File.basename(path) : relative
|
|
110
|
+
end
|
|
111
|
+
|
|
95
112
|
def add_context!(frame, path, lineno, count)
|
|
96
113
|
lines = LineCache.lines(path)
|
|
97
114
|
return if lines.nil? || lineno.nil? || lineno < 1 || lineno > lines.size
|
|
@@ -1,23 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "../scrubber"
|
|
4
5
|
|
|
5
6
|
module CloseYourIt
|
|
6
7
|
# Voce di log strutturata spedita all'ingest /logs (NON formato Sentry: i log sono uno stream con
|
|
7
|
-
# message/level/attributes/logger).
|
|
8
|
-
#
|
|
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.
|
|
9
12
|
class LogEvent < Event
|
|
10
13
|
# Livelli canonici del backend (enum) + alias dei nomi stile ::Logger. Normalizzati QUI (fonte
|
|
11
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).
|
|
12
17
|
LEVELS = %w[debug info warning error fatal].freeze
|
|
13
18
|
LEVEL_ALIASES = { "warn" => "warning", "err" => "error", "unknown" => "fatal" }.freeze
|
|
14
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.
|
|
22
|
+
def self.normalize_level(level)
|
|
23
|
+
value = level.to_s.downcase
|
|
24
|
+
value = LEVEL_ALIASES.fetch(value, value)
|
|
25
|
+
LEVELS.include?(value) ? value : "info"
|
|
26
|
+
end
|
|
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.
|
|
30
|
+
def self.severity(level)
|
|
31
|
+
LEVELS.index(normalize_level(level))
|
|
32
|
+
end
|
|
33
|
+
|
|
15
34
|
def initialize(message, level:, attributes:, configuration:, logger: nil)
|
|
16
35
|
super(configuration)
|
|
17
36
|
@message = message
|
|
18
|
-
@level = normalize_level(level)
|
|
37
|
+
@level = self.class.normalize_level(level)
|
|
19
38
|
@attributes = attributes || {}
|
|
20
39
|
@logger = logger
|
|
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.
|
|
44
|
+
@trace_id = CloseYourIt::Scope.current.trace_id
|
|
21
45
|
end
|
|
22
46
|
|
|
23
47
|
def to_h
|
|
@@ -25,10 +49,10 @@ module CloseYourIt
|
|
|
25
49
|
"event_id" => SecureRandom.uuid.delete("-"),
|
|
26
50
|
"timestamp" => @occurred_at,
|
|
27
51
|
"level" => @level,
|
|
28
|
-
"message" => @message.to_s,
|
|
52
|
+
"message" => @scrubber.scrub_message(@message.to_s),
|
|
29
53
|
"attributes" => scrubbed_attributes,
|
|
30
54
|
"logger" => @logger,
|
|
31
|
-
"trace_id" =>
|
|
55
|
+
"trace_id" => @trace_id,
|
|
32
56
|
"environment" => environment,
|
|
33
57
|
"release" => @configuration.release,
|
|
34
58
|
"sdk" => sdk
|
|
@@ -41,16 +65,10 @@ module CloseYourIt
|
|
|
41
65
|
|
|
42
66
|
private
|
|
43
67
|
|
|
44
|
-
def normalize_level(level)
|
|
45
|
-
value = level.to_s.downcase
|
|
46
|
-
value = LEVEL_ALIASES.fetch(value, value)
|
|
47
|
-
LEVELS.include?(value) ? value : "info"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
68
|
def scrubbed_attributes
|
|
51
69
|
return {} if @attributes.nil? || @attributes.empty?
|
|
52
70
|
|
|
53
|
-
|
|
71
|
+
@scrubber.filter_params(deep_stringify_keys(@attributes))
|
|
54
72
|
end
|
|
55
73
|
|
|
56
74
|
# Chiavi sempre stringa (anche annidate): coerenza col payload JSON e con la denylist dello Scrubber.
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "../scrubber"
|
|
4
5
|
|
|
5
6
|
module CloseYourIt
|
|
6
7
|
# Messaggio diagnostico esplicito (`CloseYourIt.capture_message`) nel formato evento Sentry
|
|
7
|
-
# (`message.formatted` + level). Fonde lo Scope corrente come ErrorEvent.
|
|
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.
|
|
8
10
|
class MessageEvent < Event
|
|
9
11
|
def initialize(message, level:, configuration:)
|
|
10
12
|
super(configuration)
|
|
11
13
|
@message = message
|
|
12
14
|
@level = level
|
|
15
|
+
@scrubber = Scrubber.new(configuration)
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def to_h
|
|
@@ -18,10 +21,12 @@ module CloseYourIt
|
|
|
18
21
|
"timestamp" => @occurred_at,
|
|
19
22
|
"platform" => "ruby",
|
|
20
23
|
"level" => @level,
|
|
24
|
+
# Correlazione log↔errori: stesso trace_id dei log della medesima richiesta (parità con ErrorEvent).
|
|
25
|
+
"trace_id" => CloseYourIt::Scope.current.trace_id,
|
|
21
26
|
"environment" => environment,
|
|
22
27
|
"release" => @configuration.release,
|
|
23
28
|
"server_name" => server_name,
|
|
24
|
-
"message" => { "formatted" => @message },
|
|
29
|
+
"message" => { "formatted" => @scrubber.scrub_message(@message.to_s) },
|
|
25
30
|
"contexts" => { "runtime" => { "name" => "ruby", "version" => RUBY_VERSION } },
|
|
26
31
|
"sdk" => sdk
|
|
27
32
|
)
|
|
@@ -32,6 +32,11 @@ module CloseYourIt
|
|
|
32
32
|
return if batch.empty?
|
|
33
33
|
|
|
34
34
|
@client.flush_logs(batch)
|
|
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.
|
|
39
|
+
CloseYourIt.internal_logger.error("CloseYourIt log buffer: #{e.class}: #{e.message}")
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def shutdown
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
module CloseYourIt
|
|
4
4
|
# Oggetto Logger-compatibile esposto come `CloseYourIt.logger`: ogni chiamata inoltra a
|
|
5
|
-
# `CloseYourIt.
|
|
5
|
+
# `CloseYourIt.emit_log` (→ ingest /logs). Usabile come logger esplicito dell'app, anche con attributes:
|
|
6
6
|
# CloseYourIt.logger.warn("disco quasi pieno", disk: "sda1")
|
|
7
7
|
# `warn` mappa sul livello `warning` (enum backend); supporta block e `::Logger#add` per drop-in.
|
|
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"
|
|
13
|
+
# payments.warn("retry", attempt: 3) # logger=payments, attributes={attempt:3}
|
|
14
|
+
# CloseYourIt.logger.warn("disco pieno", logger: "/dev/sda1") # attributes={logger:"/dev/sda1"}
|
|
8
15
|
class LogDevice
|
|
9
16
|
# Severità numeriche ::Logger → livelli CloseYourIt (UNKNOWN→fatal).
|
|
10
17
|
SEVERITY_LEVELS = { 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal", 5 => "fatal" }.freeze
|
|
11
18
|
|
|
19
|
+
# `source` = nome della sorgente del log (campo `logger` del payload). Preferire `.named` in app.
|
|
20
|
+
def initialize(source = nil)
|
|
21
|
+
@source = normalize_source(source)
|
|
22
|
+
end
|
|
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.
|
|
26
|
+
# CloseYourIt.logger.named("payments").info("retry", attempt: 3)
|
|
27
|
+
def named(source)
|
|
28
|
+
self.class.new(source)
|
|
29
|
+
end
|
|
30
|
+
|
|
12
31
|
def debug(message = nil, **attributes, &block) = write("debug", message, attributes, &block)
|
|
13
32
|
def info(message = nil, **attributes, &block) = write("info", message, attributes, &block)
|
|
14
33
|
def warn(message = nil, **attributes, &block) = write("warning", message, attributes, &block)
|
|
@@ -33,7 +52,17 @@ module CloseYourIt
|
|
|
33
52
|
return unless CloseYourIt.logs_active?
|
|
34
53
|
|
|
35
54
|
message = block.call if block
|
|
36
|
-
|
|
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).
|
|
57
|
+
CloseYourIt.emit_log(level, message, source: @source, attributes: attributes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Sorgente vuota/blank → nessuna sorgente (nil); altrimenti stringa (il campo `logger` è testuale).
|
|
61
|
+
def normalize_source(source)
|
|
62
|
+
return nil if source.nil?
|
|
63
|
+
|
|
64
|
+
value = source.to_s.strip
|
|
65
|
+
value.empty? ? nil : value
|
|
37
66
|
end
|
|
38
67
|
end
|
|
39
68
|
end
|
|
@@ -36,6 +36,10 @@ module CloseYourIt
|
|
|
36
36
|
context["job_id"] = job.job_id if job.respond_to?(:job_id)
|
|
37
37
|
context["executions"] = job.executions if job.respond_to?(:executions)
|
|
38
38
|
CloseYourIt.set_context("active_job", context) unless context.empty?
|
|
39
|
+
|
|
40
|
+
# Un trace_id stabile per esecuzione (job_id è unico per run ActiveJob): log ed errore dello
|
|
41
|
+
# stesso job condividono il trace_id → correlazione log↔errore anche fuori dal ciclo richiesta.
|
|
42
|
+
CloseYourIt::Scope.current.trace_id = job.job_id if job.respond_to?(:job_id)
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
end
|
|
@@ -5,15 +5,17 @@ require "logger"
|
|
|
5
5
|
module CloseYourIt
|
|
6
6
|
module Rails
|
|
7
7
|
# Sink Logger-compatibile agganciato a Rails.logger (broadcast opt-in, `config.capture_rails_logs`):
|
|
8
|
-
# ogni log dell'app ≥ soglia (`
|
|
9
|
-
# Sotto soglia: no-op (nessun invio).
|
|
8
|
+
# ogni log dell'app ≥ soglia (`capture_rails_logs_min_level`, default :warn) viene re-inoltrato a
|
|
9
|
+
# CloseYourIt.log → ingest /logs. Sotto soglia: no-op (nessun invio). Il default è conservativo
|
|
10
|
+
# (:warn) per non inondare lo stream col rumore info del framework (CYRB-7). Non scrive su alcun
|
|
11
|
+
# device (inoltra soltanto).
|
|
10
12
|
class LogBroadcast < ::Logger
|
|
11
13
|
SEVERITY_LEVELS = { 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal", 5 => "fatal" }.freeze
|
|
12
14
|
LEVEL_BY_SYMBOL = { debug: 0, info: 1, warn: 2, warning: 2, error: 3, fatal: 4 }.freeze
|
|
13
15
|
|
|
14
|
-
def initialize(min_level = :
|
|
16
|
+
def initialize(min_level = :warn)
|
|
15
17
|
super(nil) # nessun device: inoltra soltanto
|
|
16
|
-
self.level = LEVEL_BY_SYMBOL.fetch(min_level.to_sym,
|
|
18
|
+
self.level = LEVEL_BY_SYMBOL.fetch(min_level.to_sym, 2)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
# Sovrascrive il punto unico di ::Logger: filtra per soglia e re-inoltra a CloseYourIt.log.
|
|
@@ -95,7 +95,10 @@ module CloseYourIt
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
# Broadcast opt-in di Rails.logger → CloseYourIt.log (config.capture_rails_logs, default OFF).
|
|
98
|
-
# Spedisce solo i log dell'app ≥
|
|
98
|
+
# Spedisce solo i log dell'app ≥ capture_rails_logs_min_level: soglia DEDICATA (default :warn),
|
|
99
|
+
# distinta da logs_min_level, così il broadcast non inonda lo stream col rumore info del framework
|
|
100
|
+
# (Started GET, Rendered, ... — una riga per richiesta ad alto traffico, CYRB-7). Richiede
|
|
101
|
+
# BroadcastLogger (Rails 7.1+).
|
|
99
102
|
# `after: :load_config_initializers`: config.capture_rails_logs è impostato in
|
|
100
103
|
# config/initializers/closeyourit.rb (CloseYourIt.init), che gira DOPO gli initializer dei
|
|
101
104
|
# railtie. Senza questo `after:` il check leggerebbe il default (false) e il broadcast non
|
|
@@ -103,7 +106,7 @@ module CloseYourIt
|
|
|
103
106
|
initializer "closeyourit.capture_rails_logs", after: :load_config_initializers do
|
|
104
107
|
config = CloseYourIt.configuration
|
|
105
108
|
if config.capture_rails_logs && ::Rails.logger.respond_to?(:broadcast_to)
|
|
106
|
-
::Rails.logger.broadcast_to(CloseYourIt::Rails::LogBroadcast.new(config.
|
|
109
|
+
::Rails.logger.broadcast_to(CloseYourIt::Rails::LogBroadcast.new(config.capture_rails_logs_min_level))
|
|
107
110
|
end
|
|
108
111
|
end
|
|
109
112
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "rack/utils"
|
|
4
5
|
|
|
5
6
|
module CloseYourIt
|
|
6
7
|
module Rails
|
|
@@ -12,6 +13,10 @@ module CloseYourIt
|
|
|
12
13
|
# Header con prefisso non-HTTP_ in env Rack.
|
|
13
14
|
RAW_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH].freeze
|
|
14
15
|
|
|
16
|
+
# Cookie di correlazione scritto dal browser SDK (session replay): id opaco della
|
|
17
|
+
# sessione di replay, trasportato da ogni richiesta same-origin.
|
|
18
|
+
REPLAY_COOKIE = "cyi_replay"
|
|
19
|
+
|
|
15
20
|
def initialize(app)
|
|
16
21
|
@app = app
|
|
17
22
|
end
|
|
@@ -20,6 +25,9 @@ module CloseYourIt
|
|
|
20
25
|
if enabled?
|
|
21
26
|
# trace_id sempre (correlazione log↔errori), anche con capture_request OFF.
|
|
22
27
|
CloseYourIt::Scope.current.trace_id = trace_id_for(env)
|
|
28
|
+
# Correlazione errore server ↔ session replay: l'id dal cookie finisce sullo scope
|
|
29
|
+
# → contexts.replay.replay_id dell'evento (stesso punto del percorso JS).
|
|
30
|
+
CloseYourIt::Scope.current.replay_session_id = replay_session_id_for(env)
|
|
23
31
|
if CloseYourIt.configuration.capture_request
|
|
24
32
|
CloseYourIt::Scope.current.request = build_request(env)
|
|
25
33
|
# Riferimento all'env per l'estrazione LAZY del body (request.data) a evento costruito.
|
|
@@ -47,6 +55,14 @@ module CloseYourIt
|
|
|
47
55
|
SecureRandom.uuid
|
|
48
56
|
end
|
|
49
57
|
|
|
58
|
+
# Estrae `cyi_replay` dal cookie della richiesta (nil se assente). Rack puro.
|
|
59
|
+
def replay_session_id_for(env)
|
|
60
|
+
cookie = env["HTTP_COOKIE"]
|
|
61
|
+
return nil if cookie.nil? || cookie.empty?
|
|
62
|
+
|
|
63
|
+
Rack::Utils.parse_cookies_header(cookie)[REPLAY_COOKIE].to_s.then { |id| id.empty? ? nil : id }
|
|
64
|
+
end
|
|
65
|
+
|
|
50
66
|
# Forma `request` Sentry. URL senza query string; header solo dall'allowlist (mai
|
|
51
67
|
# Authorization/Cookie). query_string + IP solo con send_pii (opt-in).
|
|
52
68
|
def build_request(env)
|
data/lib/closeyourit/scope.rb
CHANGED
|
@@ -35,7 +35,7 @@ module CloseYourIt
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
attr_accessor :request, :trace_id, :rack_env
|
|
38
|
+
attr_accessor :request, :trace_id, :rack_env, :replay_session_id
|
|
39
39
|
attr_reader :user, :tags, :extra, :contexts, :breadcrumbs
|
|
40
40
|
|
|
41
41
|
def initialize
|
|
@@ -80,6 +80,7 @@ module CloseYourIt
|
|
|
80
80
|
@request = nil
|
|
81
81
|
@rack_env = nil
|
|
82
82
|
@trace_id = nil
|
|
83
|
+
@replay_session_id = nil
|
|
83
84
|
@breadcrumbs = BreadcrumbBuffer.new(CloseYourIt.configuration.max_breadcrumbs)
|
|
84
85
|
@performance_profile = nil
|
|
85
86
|
end
|
|
@@ -93,7 +94,7 @@ module CloseYourIt
|
|
|
93
94
|
"user" => serialize_user,
|
|
94
95
|
"tags" => scrub(presence(@tags)),
|
|
95
96
|
"extra" => scrub(presence(@extra)),
|
|
96
|
-
"contexts" =>
|
|
97
|
+
"contexts" => contexts_payload,
|
|
97
98
|
"request" => request_payload,
|
|
98
99
|
"breadcrumbs" => breadcrumbs_payload
|
|
99
100
|
}.reject { |_key, value| value.nil? }
|
|
@@ -101,6 +102,15 @@ module CloseYourIt
|
|
|
101
102
|
|
|
102
103
|
private
|
|
103
104
|
|
|
105
|
+
# contexts utente + `replay.replay_id` (session replay) quando presente sullo scope: il
|
|
106
|
+
# backend legge contexts.replay.replay_id per legare l'errore server al video (stesso punto
|
|
107
|
+
# del percorso JS). replay/replay_id non sono chiavi sensibili → sopravvivono allo Scrubber.
|
|
108
|
+
def contexts_payload
|
|
109
|
+
merged = @contexts.dup
|
|
110
|
+
merged["replay"] = { "replay_id" => @replay_session_id } if @replay_session_id
|
|
111
|
+
scrub(presence(merged))
|
|
112
|
+
end
|
|
113
|
+
|
|
104
114
|
# Redige i valori delle chiavi sensibili preservando la struttura (es. contexts.runtime resta
|
|
105
115
|
# intatto, solo i valori sotto chiavi sensibili diventano [FILTERED]). Riusa lo Scrubber della
|
|
106
116
|
# configurazione, lo stesso percorso di breadcrumb.data e degli attributi di log.
|
data/lib/closeyourit/scrubber.rb
CHANGED
|
@@ -6,14 +6,21 @@ module CloseYourIt
|
|
|
6
6
|
class Scrubber
|
|
7
7
|
FILTERED = "[FILTERED]"
|
|
8
8
|
|
|
9
|
-
# Token di chiavi sempre redatti (match per sottostringa, normalizzato). Allineato
|
|
10
|
-
# di backend e client Dart (parità client-side) — vedi
|
|
11
|
-
#
|
|
9
|
+
# Token di chiavi sempre redatti (match per sottostringa, normalizzato). Allineato al regex
|
|
10
|
+
# SENSITIVE_KEY di backend e client Dart (parità client-side) — vedi
|
|
11
|
+
# Errors/Logs::Ingest::Normalize::SENSITIVE_KEY. Copre credenziali/segreti e PII:
|
|
12
|
+
# /pass|secret|token|api[_-]?key|apikey|authorization|cookie|csrf|credit|card|cvv|ssn|iban|
|
|
13
|
+
# email|phone|telephone|mobile|dob|birth|passport|bearer|session|pin|pan/i
|
|
12
14
|
# `pass` copre password/passwd/pass_code/passkey/passphrase; `cookie` copre set-cookie;
|
|
13
|
-
# `credit`+`card` coprono credit_card
|
|
15
|
+
# `credit`+`card` coprono credit_card; `phone` copre telephone/smartphone; `birth` copre
|
|
16
|
+
# date_of_birth. Match per sottostringa → privilegia l'over-redaction (privacy-by-default):
|
|
17
|
+
# p.es. `company_name` (contiene `pan`) o `shipping` (contiene `pin`) vengono redatti — è
|
|
18
|
+
# accettabile, meglio redigere troppo che perdere PII.
|
|
19
|
+
# CYRB-3: la lista ometteva email/phone/dob → i bind delle query lente li leakavano nel pannello.
|
|
14
20
|
DENYLIST = %w[
|
|
15
21
|
pass secret token api_key apikey authorization
|
|
16
22
|
cookie csrf credit card cvv ssn iban
|
|
23
|
+
email phone telephone mobile dob birth passport bearer session pin pan
|
|
17
24
|
].freeze
|
|
18
25
|
|
|
19
26
|
STRING_LITERAL = /'(?:[^']|'')*'/
|
|
@@ -29,6 +29,9 @@ module CloseYourIt
|
|
|
29
29
|
config
|
|
30
30
|
)
|
|
31
31
|
CloseYourIt.capture_event(event)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
# La telemetria non deve mai disturbare la query ospite.
|
|
34
|
+
nil
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
# Breadcrumb per OGNI query non di sistema (non solo lente): SQL offuscato, niente bind.
|
|
@@ -44,6 +47,9 @@ module CloseYourIt
|
|
|
44
47
|
message: scrubber(config).obfuscate_sql(sql),
|
|
45
48
|
data: { "name" => name, "duration_ms" => duration_ms.to_f.round(2), "cached" => cached }
|
|
46
49
|
)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# La telemetria non deve mai disturbare la query ospite.
|
|
52
|
+
nil
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
# Spinge OGNI query non di sistema nel RequestProfile dello Scope (per la detection N+1 a fine
|
|
@@ -24,7 +24,7 @@ module CloseYourIt
|
|
|
24
24
|
CloseYourIt.stats.increment(:sent)
|
|
25
25
|
else
|
|
26
26
|
CloseYourIt.stats.increment(:failed)
|
|
27
|
-
CloseYourIt.internal_logger.warn("CloseYourIt transport: HTTP #{response.code} su #{path}")
|
|
27
|
+
CloseYourIt.internal_logger.warn("CloseYourIt transport: HTTP #{response.code}#{error_detail(response)} su #{path}")
|
|
28
28
|
end
|
|
29
29
|
response
|
|
30
30
|
rescue StandardError => e
|
|
@@ -37,11 +37,12 @@ module CloseYourIt
|
|
|
37
37
|
|
|
38
38
|
def post(payload, path)
|
|
39
39
|
body = JSON.generate(payload)
|
|
40
|
-
|
|
40
|
+
origin = URI.parse("#{base_url}#{path}")
|
|
41
|
+
uri = origin
|
|
41
42
|
redirects = 0
|
|
42
43
|
|
|
43
44
|
loop do
|
|
44
|
-
response = post_once(uri, body)
|
|
45
|
+
response = post_once(uri, body, authorize: same_authority?(origin, uri))
|
|
45
46
|
location = response["location"] if response.is_a?(Net::HTTPRedirection)
|
|
46
47
|
return response unless location && redirects < MAX_REDIRECTS
|
|
47
48
|
|
|
@@ -50,14 +51,16 @@ module CloseYourIt
|
|
|
50
51
|
end
|
|
51
52
|
end
|
|
52
53
|
|
|
53
|
-
def post_once(uri, body)
|
|
54
|
+
def post_once(uri, body, authorize:)
|
|
54
55
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
56
|
http.use_ssl = uri.scheme == "https"
|
|
56
57
|
http.open_timeout = OPEN_TIMEOUT
|
|
57
58
|
http.read_timeout = READ_TIMEOUT
|
|
58
59
|
|
|
59
60
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
60
|
-
|
|
61
|
+
# Il Bearer è un segreto d'ingest: non ri-inviarlo dopo un redirect verso un host diverso
|
|
62
|
+
# (o un downgrade https→http), per non consegnarlo a un endpoint non fidato.
|
|
63
|
+
request["Authorization"] = "Bearer #{@configuration.token}" if authorize
|
|
61
64
|
request["Content-Type"] = "application/json"
|
|
62
65
|
request["User-Agent"] = "closeyourit-ruby/#{VERSION}"
|
|
63
66
|
request.body = body
|
|
@@ -71,6 +74,27 @@ module CloseYourIt
|
|
|
71
74
|
target.relative? ? current + target : target
|
|
72
75
|
end
|
|
73
76
|
|
|
77
|
+
# Il Bearer va ri-inviato solo se il redirect resta sullo stesso host (o la sua variante www) e
|
|
78
|
+
# senza downgrade https→http: evita di consegnare il segreto d'ingest a un host non fidato.
|
|
79
|
+
def same_authority?(origin, target)
|
|
80
|
+
return false if origin.scheme == "https" && target.scheme != "https"
|
|
81
|
+
|
|
82
|
+
origin.host.to_s.sub(/\Awww\./, "") == target.host.to_s.sub(/\Awww\./, "")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Estrae " R…: messaggio" dall'envelope d'errore del backend, così il dev vede il codice d'errore
|
|
86
|
+
# nel log diagnostico (non solo lo status HTTP). Silenzioso se il body non è l'envelope atteso.
|
|
87
|
+
def error_detail(response)
|
|
88
|
+
body = JSON.parse(response.body.to_s)
|
|
89
|
+
error = body["error"]
|
|
90
|
+
return "" unless error.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
detail = [ error["code"], error["message"] ].compact.join(": ")
|
|
93
|
+
detail.empty? ? "" : " #{detail}"
|
|
94
|
+
rescue StandardError
|
|
95
|
+
""
|
|
96
|
+
end
|
|
97
|
+
|
|
74
98
|
def base_url
|
|
75
99
|
@configuration.endpoint_url.to_s.chomp("/")
|
|
76
100
|
end
|
data/lib/closeyourit/version.rb
CHANGED
data/lib/closeyourit-ruby.rb
CHANGED
|
@@ -165,17 +165,28 @@ module CloseYourIt
|
|
|
165
165
|
@app_logger ||= LogDevice.new
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
-
#
|
|
169
|
-
# `level` è normalizzato ai livelli canonici (`:warn`→`warning`, downcase;
|
|
170
|
-
#
|
|
168
|
+
# API esplicita: costruisce e bufferizza una voce di log strutturata (batch verso /logs,
|
|
169
|
+
# fire-and-forget). Il `level` è normalizzato ai livelli canonici (`:warn`→`warning`, downcase;
|
|
170
|
+
# ignoto→`info`). Il keyword `logger:` = nome della sorgente del log; ogni altra keyword è un
|
|
171
|
+
# attributo dati. I log sotto `logs_min_level` sono scartati con la stessa mappa numerica di
|
|
172
|
+
# dart/js (regola standardize, not adapt — CYRB-6): niente costruzione né invio.
|
|
171
173
|
# CloseYourIt.log(:info, "ordine creato", order_id: 1)
|
|
172
174
|
# CloseYourIt.log(:warn, "retry", logger: "payments", attempt: 3)
|
|
173
175
|
def log(level, message, logger: nil, **attributes)
|
|
176
|
+
emit_log(level, message, source: logger, attributes: attributes)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Percorso a basso livello (gating + costruzione + buffer) con sorgente e attributes SEPARATI e
|
|
180
|
+
# non-ambigui: usato da `LogDevice` per trattare una chiave `logger` come attributo dati (mai come
|
|
181
|
+
# sorgente) e per impostare la sorgente solo via `.named` (child logger, parità dart/js — CYRB-8).
|
|
182
|
+
# Le app usano `CloseYourIt.log` / `CloseYourIt.logger`.
|
|
183
|
+
def emit_log(level, message, source: nil, attributes: {})
|
|
174
184
|
return nil unless logs_enabled?
|
|
185
|
+
return nil if log_below_min_level?(level)
|
|
175
186
|
return nil unless logs_sampled?
|
|
176
187
|
|
|
177
188
|
event = LogEvent.new(message, level: level, attributes: attributes,
|
|
178
|
-
logger:
|
|
189
|
+
logger: source, configuration: configuration)
|
|
179
190
|
log_buffer.add(event)
|
|
180
191
|
nil
|
|
181
192
|
end
|
|
@@ -192,6 +203,19 @@ module CloseYourIt
|
|
|
192
203
|
nil
|
|
193
204
|
end
|
|
194
205
|
|
|
206
|
+
# Flush di fine-vita: svuota i log bufferizzati E drena il worker asincrono, attendendo (fino a un
|
|
207
|
+
# timeout breve) che gli invii in volo verso l'ingest completino. Solo flushare il buffer li
|
|
208
|
+
# accoderebbe nel worker che verrebbe poi ucciso alla terminazione → log persi (CYRB-5). Drena
|
|
209
|
+
# TUTTO il worker (log + errori + metriche fire-and-forget), non solo i log. Registrato su at_exit
|
|
210
|
+
# da CloseYourIt.init; con `config.trap_signals` viene raggiunto anche su SIGTERM. Idempotente:
|
|
211
|
+
# richiamarlo è sicuro (buffer già vuoto, worker già fermo → no-op).
|
|
212
|
+
def shutdown
|
|
213
|
+
# Ordine critico: prima il buffer (accoda l'ultimo batch nel worker), poi il worker (lo drena).
|
|
214
|
+
@log_buffer&.shutdown
|
|
215
|
+
@client&.shutdown
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
195
219
|
# Contatori diagnostici del client (accodati/scartati/spediti/falliti).
|
|
196
220
|
# CloseYourIt.stats.to_h # => { enqueued: …, dropped: …, sent: …, failed: … }
|
|
197
221
|
def stats
|
|
@@ -213,6 +237,12 @@ module CloseYourIt
|
|
|
213
237
|
enabled? && configuration.logs_enabled
|
|
214
238
|
end
|
|
215
239
|
|
|
240
|
+
# Gating per soglia: il log è scartato se la sua severità è sotto `logs_min_level`. La severità è
|
|
241
|
+
# la mappa numerica di LogEvent (debug=0 … fatal=4), identica cross-SDK → filtra come dart/js.
|
|
242
|
+
def log_below_min_level?(level)
|
|
243
|
+
LogEvent.severity(level) < LogEvent.severity(configuration.logs_min_level)
|
|
244
|
+
end
|
|
245
|
+
|
|
216
246
|
# Sampling indipendente dei log (1.0 = tutti, 0.0 = nessuno).
|
|
217
247
|
def logs_sampled?
|
|
218
248
|
rate = configuration.logs_sample_rate.to_f
|
|
@@ -222,13 +252,36 @@ module CloseYourIt
|
|
|
222
252
|
Random.rand < rate
|
|
223
253
|
end
|
|
224
254
|
|
|
225
|
-
# Allo shutdown del processo svuota i log bufferizzati e
|
|
226
|
-
#
|
|
255
|
+
# Allo shutdown del processo svuota i log bufferizzati e drena il worker asincrono. Senza, i log
|
|
256
|
+
# sotto-batch dei processi brevi (rake/CLI) e gli ultimi eventi accodati andrebbero persi all'uscita.
|
|
257
|
+
# Con `config.trap_signals` intercetta anche SIGTERM, che di default salta gli at_exit (deploy/Kamal
|
|
258
|
+
# perderebbero il flush). Vedi #shutdown (CYRB-5).
|
|
259
|
+
#
|
|
260
|
+
# L'at_exit si registra una sola volta per processo (@shutdown_registered evita N flush da init
|
|
261
|
+
# multiple), ma il trap SIGTERM è valutato PRIMA del guard e a OGNI init: deve seguire la config
|
|
262
|
+
# CORRENTE, altrimenti una init che attiva `trap_signals` dopo una prima con la flag OFF non lo
|
|
263
|
+
# installerebbe mai (il guard di idempotenza ritornerebbe prima). `Signal.trap` è idempotente →
|
|
264
|
+
# reinstallare lo stesso handler è innocuo.
|
|
227
265
|
def register_shutdown_flush
|
|
266
|
+
install_term_trap if configuration.trap_signals
|
|
267
|
+
|
|
228
268
|
return if @shutdown_registered
|
|
229
269
|
|
|
230
270
|
@shutdown_registered = true
|
|
231
|
-
at_exit {
|
|
271
|
+
at_exit { shutdown }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# SIGTERM termina il processo SENZA eseguire gli at_exit → il flush di fine-vita non girerebbe.
|
|
275
|
+
# Convertiamo TERM in un exit pulito: `exit` solleva SystemExit sul thread principale, che ESEGUE
|
|
276
|
+
# gli at_exit (incluso #shutdown) in contesto normale — niente lavoro pesante nel trap handler
|
|
277
|
+
# (Mutex/wait_for_termination in trap context solleverebbero ThreadError). Opt-in: sovrascrive un
|
|
278
|
+
# eventuale handler TERM dell'app ospite.
|
|
279
|
+
def install_term_trap
|
|
280
|
+
Signal.trap("TERM") { exit }
|
|
281
|
+
rescue ArgumentError, RuntimeError => e
|
|
282
|
+
# Contesti dove TERM non è trap-abile (piattaforma senza TERM, thread non-main): non fatale,
|
|
283
|
+
# resta comunque l'at_exit per le uscite normali.
|
|
284
|
+
internal_logger.warn("CloseYourIt: trap SIGTERM non installato (#{e.class}: #{e.message})")
|
|
232
285
|
end
|
|
233
286
|
|
|
234
287
|
def ignored_exception?(exception)
|