closeyourit-ruby 0.7.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +19 -0
- data/lib/closeyourit/client.rb +16 -0
- data/lib/closeyourit/configuration.rb +26 -2
- data/lib/closeyourit/rails/active_job_extension.rb +60 -4
- data/lib/closeyourit/rails/net_http_patch.rb +50 -0
- data/lib/closeyourit/rails/railtie.rb +8 -2
- data/lib/closeyourit/rails/request_context.rb +19 -2
- data/lib/closeyourit/scope.rb +11 -1
- data/lib/closeyourit/trace_context.rb +109 -0
- data/lib/closeyourit/usage_registry.rb +99 -0
- data/lib/closeyourit/version.rb +1 -1
- data/lib/closeyourit-ruby.rb +23 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a23acf258deb222638d8d9bd85f57ffc6fbf36429f9c87b50896814b0245e2e3
|
|
4
|
+
data.tar.gz: 7e0cb657fde07728331a131932242fb58685929af5ac88ffc7bfdf707ba3e301
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 964551d2a915c4c150dd586362dd110255f361bf8c93e20f8060de7f7c253f737d25d48f1776108f5556f6739e2c909af83dc71c4f1e568ad09797a99a1a755e
|
|
7
|
+
data.tar.gz: b371074aed62189a65b68f8ce3ad9725f96650b6f11fd871bfd228efdf30c3c2c6331fb93990869230c7e211d3191c3690f609f5873503cf1736a44e37bcc28c
|
data/README.md
CHANGED
|
@@ -105,6 +105,25 @@ end
|
|
|
105
105
|
| `slow_job_threshold_ms` | `5000` | Durata del job (ms) oltre cui = `slow_job` |
|
|
106
106
|
| `job_queue_latency_threshold_ms` | `60000` | Attesa in coda (enqueue→esecuzione, ms) oltre cui = `job_queue_latency` |
|
|
107
107
|
| `jobs_sample_rate` | `1.0` | Frazione dei job oltre soglia effettivamente inviata (`1.0` tutti, `0.0` niente) |
|
|
108
|
+
| `propagate_trace_context` | `false` | Propaga il **trace context W3C** (`traceparent`/`tracestate`) alle chiamate `Net::HTTP` e mappa il `trace_id` degli eventi sul trace-id W3C entrante (vedi [Propagazione W3C](#propagazione-w3c-opt-in)) |
|
|
109
|
+
| `trace_propagation_allowlist` | `[]` | Host autorizzati a ricevere gli header W3C — **String** (host esatto, case-insensitive) o **Regexp** (sottodomini). Vuoto = nessuna destinazione |
|
|
110
|
+
|
|
111
|
+
## Propagazione W3C (opt-in)
|
|
112
|
+
|
|
113
|
+
Correla le richieste Ruby e le chiamate in uscita con lo standard **[W3C Trace Context](https://www.w3.org/TR/trace-context/)**
|
|
114
|
+
(`traceparent`/`tracestate`) — nessun formato proprietario. È un *propagation bridge*, non un tracer
|
|
115
|
+
completo: adotta il contesto entrante o ne genera uno root, e lo inoltra alle chiamate `Net::HTTP`.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
CloseYourIt.init do |c|
|
|
119
|
+
c.propagate_trace_context = true
|
|
120
|
+
c.trace_propagation_allowlist = [ "api.interno.example", /\.svc\.internal\z/ ]
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- **Ingresso**: un `traceparent` entrante valido diventa il `trace_id` degli eventi CloseYourIt → l'errore/metrica della richiesta si allinea alla traccia distribuita. Un header malformato viene ignorato (si genera un root); un servizio d'origine (senza header) parte comunque con un contesto W3C.
|
|
125
|
+
- **Uscita**: gli header vengono iniettati **solo** verso gli host della `trace_propagation_allowlist` (limitata per destinazione) e mai verso l'endpoint CloseYourIt stesso. Un redirect verso un host non elencato non riceve nulla.
|
|
126
|
+
- **Privacy**: verso host non autorizzati non parte alcun header; l'header `baggage` (che può portare contesto interno/PII) **non viene mai** emesso.
|
|
108
127
|
|
|
109
128
|
## Cosa cattura
|
|
110
129
|
|
data/lib/closeyourit/client.rb
CHANGED
|
@@ -78,6 +78,22 @@ module CloseYourIt
|
|
|
78
78
|
payloads
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# CYSK-29 — il flush della telemetria d'uso: UNA POST per finestra, payload privo di dati utente
|
|
82
|
+
# per costruzione (route = Controller#action). Fire-and-forget via worker, come tutto il resto.
|
|
83
|
+
def flush_usage(symbols:, truncated:, window_started_at:, window_ended_at:)
|
|
84
|
+
payload = {
|
|
85
|
+
environment: @configuration.environment.to_s,
|
|
86
|
+
release: @configuration.release,
|
|
87
|
+
sdk: { name: "closeyourit-ruby", version: CloseYourIt::VERSION },
|
|
88
|
+
window_started_at: window_started_at, window_ended_at: window_ended_at,
|
|
89
|
+
truncated: truncated, symbols: symbols
|
|
90
|
+
}
|
|
91
|
+
path = "/api/v1/projects/#{@configuration.project_id}/usages"
|
|
92
|
+
accepted = @worker.perform { @transport.send_event(payload, path: path) }
|
|
93
|
+
CloseYourIt.notify_diagnostic(:enqueue, path: path, batch: symbols.size) if accepted
|
|
94
|
+
payload
|
|
95
|
+
end
|
|
96
|
+
|
|
81
97
|
def shutdown
|
|
82
98
|
@worker.shutdown
|
|
83
99
|
end
|
|
@@ -41,10 +41,11 @@ 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
|
|
44
|
+
:jobs_sample_rate, :propagate_trace_context,
|
|
45
|
+
:usage_enabled, :usage_flush_interval, :usage_max_symbols
|
|
45
46
|
attr_writer :release, :project_root
|
|
46
47
|
attr_reader :excluded_exceptions, :excluded_log_patterns, :excluded_query_patterns,
|
|
47
|
-
:filter_parameters, :scrub_message_patterns
|
|
48
|
+
:filter_parameters, :scrub_message_patterns, :trace_propagation_allowlist
|
|
48
49
|
|
|
49
50
|
def initialize
|
|
50
51
|
@endpoint_url = ENV.fetch("CLOSEYOURIT_ENDPOINT_URL", nil)
|
|
@@ -112,6 +113,14 @@ module CloseYourIt
|
|
|
112
113
|
@logs_sample_rate = 1.0
|
|
113
114
|
@logs_batch_size = 50
|
|
114
115
|
@logs_flush_interval = 5
|
|
116
|
+
|
|
117
|
+
# CYSK-29 — telemetria d'uso: quali rotte/job/chiavi girano davvero. Il payload è privo di
|
|
118
|
+
# dati utente per costruzione (route = Controller#action, mai l'URL), quindi il default è ON:
|
|
119
|
+
# trenta giorni di raccolta facoltativa hanno insegnato che facoltativo significa mai.
|
|
120
|
+
# Il registro si svuota a ogni flush: il tetto limita i simboli DISTINTI per finestra.
|
|
121
|
+
@usage_enabled = true
|
|
122
|
+
@usage_flush_interval = 300
|
|
123
|
+
@usage_max_symbols = 2000
|
|
115
124
|
# Broadcast opt-in di Rails.logger → CloseYourIt.log (default OFF; spedisce solo ≥ soglia).
|
|
116
125
|
@capture_rails_logs = false
|
|
117
126
|
@logs_min_level = :info
|
|
@@ -148,6 +157,15 @@ module CloseYourIt
|
|
|
148
157
|
@job_queue_latency_threshold_ms = 60_000 # attesa enqueue→esecuzione oltre cui = job_queue_latency
|
|
149
158
|
@jobs_sample_rate = 1.0 # frazione dei candidati oltre soglia effettivamente inviata
|
|
150
159
|
|
|
160
|
+
# Propagazione W3C trace context (traceparent/tracestate) verso i servizi esterni chiamati via
|
|
161
|
+
# Net::HTTP. OPT-IN, default OFF: iniettare header d'uscita attraversa un trust boundary e va deciso
|
|
162
|
+
# per-app. È limitata PER DESTINAZIONE dalla allowlist (host esatti, case-insensitive, o Regexp per i
|
|
163
|
+
# sottodomini); lista vuota = nessuna destinazione. Mai verso host non elencati, mai come `baggage`
|
|
164
|
+
# (che può portare PII). In ingresso un traceparent valido diventa il trace_id degli eventi
|
|
165
|
+
# CloseYourIt → gli errori/metriche della richiesta si correlano alla traccia distribuita (CYRB-15).
|
|
166
|
+
@propagate_trace_context = false
|
|
167
|
+
@trace_propagation_allowlist = []
|
|
168
|
+
|
|
151
169
|
# Radice del progetto: base per il filename relativo dei frame (culprit cross-SDK). Lazy:
|
|
152
170
|
# auto-rilevata da Rails.root o Dir.pwd al primo accesso se non impostata esplicitamente.
|
|
153
171
|
@project_root = nil
|
|
@@ -177,6 +195,12 @@ module CloseYourIt
|
|
|
177
195
|
@filter_parameters = Array(list)
|
|
178
196
|
end
|
|
179
197
|
|
|
198
|
+
# Destinazioni autorizzate a ricevere gli header di trace W3C. String = host esatto (match
|
|
199
|
+
# case-insensitive), Regexp = pattern (per sottodomini/famiglie di host). Lista vuota = nessuno.
|
|
200
|
+
def trace_propagation_allowlist=(list)
|
|
201
|
+
@trace_propagation_allowlist = Array(list)
|
|
202
|
+
end
|
|
203
|
+
|
|
180
204
|
def scrub_message_patterns=(list)
|
|
181
205
|
@scrub_message_patterns = Array(list)
|
|
182
206
|
end
|
|
@@ -3,17 +3,45 @@
|
|
|
3
3
|
module CloseYourIt
|
|
4
4
|
module Rails
|
|
5
5
|
# Incluso in ActiveJob::Base (via railtie `on_load(:active_job)`): cattura gli errori dei job
|
|
6
|
-
# (oggi persi) con il contesto del job
|
|
6
|
+
# (oggi persi) con il contesto del job. La logica vive in `.monitor`/`.report_discarded` per essere
|
|
7
7
|
# testabile senza ActiveSupport/ActiveJob.
|
|
8
|
+
#
|
|
9
|
+
# CYRB-19: `around_perform` gira DENTRO `perform_now`, mentre `retry_on`/`discard_on` (rescue_from)
|
|
10
|
+
# sono valutati FUORI, dopo i callback. Catturare nell'around_perform significa quindi segnalare
|
|
11
|
+
# OGNI tentativo — anche quelli che verranno ritentati con successo — perché il reporter vede
|
|
12
|
+
# l'errore prima che il retry possa zittirlo, e ogni tentativo solleva una nuova istanza (la
|
|
13
|
+
# deduplica interna, per-istanza, non interviene). Su ActiveJob 7.1+ deleghiamo la segnalazione ad
|
|
14
|
+
# `after_discard`, che Rails invoca al fallimento definitivo (retry_on esauriti o eccezione non
|
|
15
|
+
# gestita), mai sui tentativi che `retry_on` ritenta → una sola occorrenza per job.
|
|
16
|
+
#
|
|
17
|
+
# Limiti noti (per costruzione di ActiveJob, non del client):
|
|
18
|
+
# - `after_discard` NON scatta per gli errori intercettati da un `rescue_from` custom: sono gestiti
|
|
19
|
+
# dall'app, quindi non li segnaliamo più come "non gestiti" (prima lo facevamo, impropriamente).
|
|
20
|
+
# - I retry a livello di ADAPTER (es. Sidekiq) SENZA `retry_on` risollevano l'errore non gestito:
|
|
21
|
+
# `after_discard` scatta a ogni esecuzione, quindi lì la de-duplicazione per tentativo non si
|
|
22
|
+
# applica (comportamento invariato rispetto a prima).
|
|
23
|
+
# Sulle versioni prive di `after_discard` restiamo al fallback legacy (cattura nell'around_perform).
|
|
8
24
|
module ActiveJobExtension
|
|
25
|
+
# Scope arricchito durante il `perform` tramandato ad `after_discard`. Legato all'ISTANZA del job
|
|
26
|
+
# (ogni retry ne crea una nuova, deserializzata) → vive esattamente quanto serve, niente bleed tra
|
|
27
|
+
# job o thread; il reset di fine `perform` lo sgancia solo dallo storage, non muta l'oggetto.
|
|
28
|
+
STASHED_SCOPE_IVAR = :@__closeyourit_stashed_scope
|
|
29
|
+
|
|
9
30
|
def self.included(base)
|
|
10
31
|
base.around_perform do |job, block|
|
|
11
32
|
CloseYourIt::Rails::ActiveJobExtension.monitor(job) { block.call }
|
|
12
33
|
end
|
|
34
|
+
|
|
35
|
+
return unless base.respond_to?(:after_discard)
|
|
36
|
+
|
|
37
|
+
base.after_discard do |job, exception|
|
|
38
|
+
CloseYourIt::Rails::ActiveJobExtension.report_discarded(job, exception)
|
|
39
|
+
end
|
|
13
40
|
end
|
|
14
41
|
|
|
15
|
-
# Esegue il job arricchendo lo scope con tag/context;
|
|
16
|
-
#
|
|
42
|
+
# Esegue il job arricchendo lo scope con tag/context; resetta lo scope a fine job (no bleed tra
|
|
43
|
+
# job sullo stesso thread). Su ActiveJob 7.1+ NON cattura l'errore (lo fa `after_discard` solo al
|
|
44
|
+
# fallimento definitivo): qui tramanda lo scope al job e ri-solleva. Sul fallback legacy cattura.
|
|
17
45
|
def self.monitor(job)
|
|
18
46
|
return yield unless CloseYourIt.configuration.report_active_job_errors
|
|
19
47
|
|
|
@@ -21,13 +49,41 @@ module CloseYourIt
|
|
|
21
49
|
apply_job_scope(job)
|
|
22
50
|
yield
|
|
23
51
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
24
|
-
|
|
52
|
+
if report_on_discard?(job)
|
|
53
|
+
job.instance_variable_set(STASHED_SCOPE_IVAR, CloseYourIt::Scope.current)
|
|
54
|
+
else
|
|
55
|
+
CloseYourIt.capture_exception(e, handled: false)
|
|
56
|
+
end
|
|
25
57
|
raise
|
|
26
58
|
ensure
|
|
27
59
|
CloseYourIt::Scope.reset!
|
|
28
60
|
end
|
|
29
61
|
end
|
|
30
62
|
|
|
63
|
+
# Aggancio `after_discard` (ActiveJob 7.1+): il job è definitivamente fallito, quindi segnaliamo
|
|
64
|
+
# l'errore UNA sola volta (handled:false). Riprendiamo lo scope arricchito durante il `perform`
|
|
65
|
+
# (tramandato da `monitor`) così il report conserva breadcrumb/tag/contesti raccolti nel job;
|
|
66
|
+
# `apply_job_scope` rinfresca i campi standard con `executions` finale senza perdere i custom.
|
|
67
|
+
def self.report_discarded(job, exception)
|
|
68
|
+
return unless CloseYourIt.configuration.report_active_job_errors
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
stashed = job.instance_variable_get(STASHED_SCOPE_IVAR)
|
|
72
|
+
CloseYourIt::Scope.current = stashed if stashed
|
|
73
|
+
apply_job_scope(job)
|
|
74
|
+
CloseYourIt.capture_exception(exception, handled: false)
|
|
75
|
+
ensure
|
|
76
|
+
job.remove_instance_variable(STASHED_SCOPE_IVAR) if job.instance_variable_defined?(STASHED_SCOPE_IVAR)
|
|
77
|
+
CloseYourIt::Scope.reset!
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# true quando ActiveJob espone `after_discard` (7.1+): la segnalazione è delegata lì, così
|
|
82
|
+
# `monitor` non cattura i tentativi intermedi. false → fallback legacy (cattura nell'around_perform).
|
|
83
|
+
def self.report_on_discard?(job)
|
|
84
|
+
job.class.respond_to?(:after_discard)
|
|
85
|
+
end
|
|
86
|
+
|
|
31
87
|
def self.apply_job_scope(job)
|
|
32
88
|
CloseYourIt.set_tag("job.class", job.class.name)
|
|
33
89
|
CloseYourIt.set_tag("job.queue", job.queue_name) if job.respond_to?(:queue_name)
|
|
@@ -12,6 +12,9 @@ module CloseYourIt
|
|
|
12
12
|
module NetHTTPPatch
|
|
13
13
|
def request(req, body = nil, &block)
|
|
14
14
|
config = CloseYourIt.configuration
|
|
15
|
+
# Propagazione W3C: indipendente dal profiling (ha il suo opt-in), va fatta PRIMA del round-trip
|
|
16
|
+
# perché aggiunge header alla richiesta in uscita.
|
|
17
|
+
inject_trace_context(config, req)
|
|
15
18
|
return super unless config.detect_performance_issues && config.capture_external_http
|
|
16
19
|
|
|
17
20
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -25,6 +28,53 @@ module CloseYourIt
|
|
|
25
28
|
|
|
26
29
|
private
|
|
27
30
|
|
|
31
|
+
# Gestisce gli header di trace W3C sulla richiesta in uscita (solo con la propagazione opt-in ON).
|
|
32
|
+
# Verso una destinazione autorizzata inietta traceparent/tracestate; verso qualsiasi altra li
|
|
33
|
+
# RIMUOVE. La rimozione è la difesa contro il leak su redirect cross-host: se lo stesso oggetto
|
|
34
|
+
# request viene riusato per seguire un redirect verso un host non in allowlist, gli header della
|
|
35
|
+
# chiamata precedente non devono sopravvivere ("host esterni non ricevono header interni" — CYRB-15).
|
|
36
|
+
# baggage non viene mai né letto né emesso. Difensivo: mai solleva per colpa della propagazione.
|
|
37
|
+
def inject_trace_context(config, req)
|
|
38
|
+
return unless config.propagate_trace_context
|
|
39
|
+
|
|
40
|
+
context = deliverable_context(config)
|
|
41
|
+
if context.nil?
|
|
42
|
+
strip_trace_headers(req)
|
|
43
|
+
else
|
|
44
|
+
context.headers.each { |name, value| req[name] = value }
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Il trace context da consegnare a QUESTA destinazione, o nil se non va propagato nulla: host
|
|
51
|
+
# assente, endpoint CloseYourIt stesso (niente auto-propagazione), destinazione fuori allowlist,
|
|
52
|
+
# o scope privo di contesto.
|
|
53
|
+
def deliverable_context(config)
|
|
54
|
+
host = address
|
|
55
|
+
return nil if host.nil? || own_endpoint?(config, host)
|
|
56
|
+
return nil unless destination_allowed?(config, host)
|
|
57
|
+
|
|
58
|
+
CloseYourIt::Scope.current.trace_context
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# La destinazione è autorizzata a ricevere il trace context? String = host esatto (case-insensitive),
|
|
62
|
+
# Regexp = pattern (sottodomini/famiglie). Lista vuota → sempre false (nessuna destinazione).
|
|
63
|
+
def destination_allowed?(config, host)
|
|
64
|
+
config.trace_propagation_allowlist.any? do |pattern|
|
|
65
|
+
pattern.is_a?(Regexp) ? pattern.match?(host) : pattern.to_s.casecmp?(host)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Rimuove gli header di trace W3C che una chiamata precedente sullo stesso oggetto request possa
|
|
70
|
+
# aver lasciato. Solo i nostri header, mai altro; no-op se il request non li supporta.
|
|
71
|
+
def strip_trace_headers(req)
|
|
72
|
+
return unless req.respond_to?(:delete)
|
|
73
|
+
|
|
74
|
+
req.delete("traceparent")
|
|
75
|
+
req.delete("tracestate")
|
|
76
|
+
end
|
|
77
|
+
|
|
28
78
|
def record_external(config, req, duration_ms)
|
|
29
79
|
host = address
|
|
30
80
|
return if host.nil? || own_endpoint?(config, host)
|
|
@@ -68,6 +68,9 @@ module CloseYourIt
|
|
|
68
68
|
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
69
69
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
70
70
|
payload = event.payload
|
|
71
|
+
# CYSK-29 — telemetria d'uso: la rotta è `Controller#action`, MAI l'URL. Il registro fa
|
|
72
|
+
# una lookup e un increment; il gate usage_enabled sta dentro #record.
|
|
73
|
+
CloseYourIt.usage_registry.record("route", "#{payload[:controller]}##{payload[:action]}") if CloseYourIt.enabled?
|
|
71
74
|
perf.record(
|
|
72
75
|
route: "#{payload[:controller]}##{payload[:action]}",
|
|
73
76
|
duration_ms: event.duration
|
|
@@ -75,8 +78,9 @@ module CloseYourIt
|
|
|
75
78
|
end
|
|
76
79
|
end
|
|
77
80
|
|
|
78
|
-
# Strumenta
|
|
79
|
-
#
|
|
81
|
+
# Strumenta Net::HTTP: rileva le chiamate esterne lente (solo con detect_performance_issues) e
|
|
82
|
+
# propaga il trace context W3C (solo con propagate_trace_context). Prepend incondizionato — i due
|
|
83
|
+
# opt-in sono valutati per-chiamata nel patch; con entrambi OFF è di fatto no-op (chiama super).
|
|
80
84
|
initializer "closeyourit.instrument_net_http" do
|
|
81
85
|
require "net/http"
|
|
82
86
|
::Net::HTTP.prepend(CloseYourIt::Rails::NetHTTPPatch) unless ::Net::HTTP.ancestors.include?(CloseYourIt::Rails::NetHTTPPatch)
|
|
@@ -103,6 +107,8 @@ module CloseYourIt
|
|
|
103
107
|
ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
|
|
104
108
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
105
109
|
job = event.payload[:job]
|
|
110
|
+
# CYSK-29 — anche i job dichiarano di essere girati: kind `job`, simbolo = la classe.
|
|
111
|
+
CloseYourIt.usage_registry.record("job", job.class.name) if job && CloseYourIt.enabled?
|
|
106
112
|
jobs.active_job_performed(job, event.duration) if job
|
|
107
113
|
end
|
|
108
114
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "../trace_context"
|
|
4
5
|
|
|
5
6
|
module CloseYourIt
|
|
6
7
|
module Rails
|
|
@@ -22,8 +23,13 @@ module CloseYourIt
|
|
|
22
23
|
|
|
23
24
|
def call(env)
|
|
24
25
|
if enabled?
|
|
25
|
-
#
|
|
26
|
-
|
|
26
|
+
# Contesto di trace W3C (solo con propagazione opt-in): adottato dall'header entrante o generato.
|
|
27
|
+
context = trace_context_for(env)
|
|
28
|
+
CloseYourIt::Scope.current.trace_context = context
|
|
29
|
+
# trace_id sempre (correlazione log↔errori), anche con capture_request OFF. Con un trace context
|
|
30
|
+
# W3C il trace_id degli eventi È il trace-id W3C → l'errore/metrica si allinea alla traccia
|
|
31
|
+
# distribuita propagata a valle (mapping trace_id, CYRB-15); altrimenti request_id come prima.
|
|
32
|
+
CloseYourIt::Scope.current.trace_id = context ? context.trace_id : trace_id_for(env)
|
|
27
33
|
# Correlazione errore server ↔ session replay: l'id dal cookie finisce sullo scope
|
|
28
34
|
# → contexts.replay.replay_id dell'evento (stesso punto del percorso JS).
|
|
29
35
|
CloseYourIt::Scope.current.replay_session_id = replay_session_id_for(env)
|
|
@@ -46,6 +52,17 @@ module CloseYourIt
|
|
|
46
52
|
false
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# Contesto di trace W3C della richiesta, SOLO con la propagazione opt-in attiva (altrimenti nil →
|
|
56
|
+
# comportamento storico invariato). Adotta un traceparent/tracestate entrante valido (correlazione
|
|
57
|
+
# distribuita), altrimenti genera un root: così anche i servizi che ORIGINANO traffico partono con
|
|
58
|
+
# un trace-id W3C propagabile a valle. Un traceparent malformato è ignorato → si genera un root.
|
|
59
|
+
def trace_context_for(env)
|
|
60
|
+
return nil unless CloseYourIt.configuration.propagate_trace_context
|
|
61
|
+
|
|
62
|
+
CloseYourIt::TraceContext.parse(env["HTTP_TRACEPARENT"], env["HTTP_TRACESTATE"]) ||
|
|
63
|
+
CloseYourIt::TraceContext.generate
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
# Riusa il request id di Rails/Rack se presente (stessa correlazione dei log applicativi),
|
|
50
67
|
# altrimenti ne genera uno.
|
|
51
68
|
def trace_id_for(env)
|
data/lib/closeyourit/scope.rb
CHANGED
|
@@ -18,6 +18,13 @@ module CloseYourIt
|
|
|
18
18
|
store[STORAGE_KEY] ||= new
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Re-installa uno scope salvato in precedenza. Serve a `after_discard` (ActiveJob 7.1+) per
|
|
22
|
+
# riprendere lo scope arricchito durante il `perform` — tag/contesti/breadcrumb, incluse le query —
|
|
23
|
+
# dopo che il reset di fine `perform` l'ha sganciato dallo storage (CYRB-19). Simmetrico a `reset!`.
|
|
24
|
+
def current=(scope)
|
|
25
|
+
store[STORAGE_KEY] = scope
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
# Azzera lo scope corrente — chiamato in `ensure` da middleware e job (su Puma il
|
|
22
29
|
# thread è riusato: senza reset lo scope colerebbe nella richiesta successiva).
|
|
23
30
|
def reset!
|
|
@@ -35,7 +42,7 @@ module CloseYourIt
|
|
|
35
42
|
end
|
|
36
43
|
end
|
|
37
44
|
|
|
38
|
-
attr_accessor :request, :trace_id, :rack_env, :replay_session_id
|
|
45
|
+
attr_accessor :request, :trace_id, :rack_env, :replay_session_id, :trace_context
|
|
39
46
|
attr_reader :user, :tags, :extra, :contexts, :breadcrumbs
|
|
40
47
|
|
|
41
48
|
def initialize
|
|
@@ -81,6 +88,9 @@ module CloseYourIt
|
|
|
81
88
|
@rack_env = nil
|
|
82
89
|
@trace_id = nil
|
|
83
90
|
@replay_session_id = nil
|
|
91
|
+
# Contesto di trace W3C della richiesta (CloseYourIt::TraceContext): popolato solo con la
|
|
92
|
+
# propagazione opt-in attiva, consumato dal patch Net::HTTP per gli header d'uscita.
|
|
93
|
+
@trace_context = nil
|
|
84
94
|
@breadcrumbs = BreadcrumbBuffer.new(CloseYourIt.configuration.max_breadcrumbs)
|
|
85
95
|
@performance_profile = nil
|
|
86
96
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module CloseYourIt
|
|
6
|
+
# Contesto di trace W3C (`traceparent`/`tracestate`, https://www.w3.org/TR/trace-context/).
|
|
7
|
+
# NON è un tracer: non apre span né misura tempi. È un "propagation bridge" minimale (il ticket:
|
|
8
|
+
# "Non costruire tracing custom completo") — adotta un contesto entrante valido facendo pass-through
|
|
9
|
+
# di trace-id/parent-id/flag, oppure ne genera uno root, e sa serializzarsi negli header d'uscita.
|
|
10
|
+
# Volutamente separato dai formati proprietari: sullo standard, senza dipendere da un vendor.
|
|
11
|
+
class TraceContext
|
|
12
|
+
# traceparent = version "-" trace-id "-" parent-id "-" trace-flags (55 char per la versione 00).
|
|
13
|
+
# `rest` cattura eventuali campi futuri: ammessi solo da versioni > 00 (forward-compat), vietati su 00.
|
|
14
|
+
TRACEPARENT = /
|
|
15
|
+
\A
|
|
16
|
+
(?<version>[0-9a-f]{2})-
|
|
17
|
+
(?<trace_id>[0-9a-f]{32})-
|
|
18
|
+
(?<parent_id>[0-9a-f]{16})-
|
|
19
|
+
(?<flags>[0-9a-f]{2})
|
|
20
|
+
(?<rest>-.*)?
|
|
21
|
+
\z
|
|
22
|
+
/x
|
|
23
|
+
|
|
24
|
+
FORBIDDEN_VERSION = "ff"
|
|
25
|
+
CURRENT_VERSION = "00"
|
|
26
|
+
ZERO_TRACE_ID = ("0" * 32).freeze
|
|
27
|
+
ZERO_PARENT_ID = ("0" * 16).freeze
|
|
28
|
+
FLAG_SAMPLED = 0x01
|
|
29
|
+
|
|
30
|
+
# tracestate: lista di membri `key=value` separati da virgola, max 32 (W3C §3.3.1).
|
|
31
|
+
TRACESTATE_MAX_MEMBERS = 32
|
|
32
|
+
# key: lowercase alnum iniziale + set ristretto (incluso `@`/`/` per le chiavi tenant@vendor).
|
|
33
|
+
# value: caratteri stampabili 0x20–0x7E esclusi `,` (0x2C) e `=` (0x3D).
|
|
34
|
+
TRACESTATE_MEMBER = %r{\A[a-z0-9][a-z0-9_\-*/@]*=[\x20-\x2b\x2d-\x3c\x3e-\x7e]+\z}
|
|
35
|
+
|
|
36
|
+
attr_reader :trace_id, :parent_id, :flags, :tracestate
|
|
37
|
+
|
|
38
|
+
def initialize(trace_id:, parent_id:, flags:, tracestate: nil)
|
|
39
|
+
@trace_id = trace_id
|
|
40
|
+
@parent_id = parent_id
|
|
41
|
+
@flags = flags
|
|
42
|
+
@tracestate = tracestate
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Adotta un traceparent entrante. Ritorna nil se malformato (→ il chiamante genera un root o
|
|
47
|
+
# lascia il contesto assente). Pass-through: mantiene trace-id/parent-id/flag verbatim così il
|
|
48
|
+
# bridge non inventa span. Il tracestate viene sanificato (membri invalidi scartati, cap a 32).
|
|
49
|
+
def parse(traceparent, tracestate = nil)
|
|
50
|
+
match = TRACEPARENT.match(traceparent.to_s.strip)
|
|
51
|
+
return nil unless match
|
|
52
|
+
return nil if match[:version] == FORBIDDEN_VERSION
|
|
53
|
+
return nil if match[:version] == CURRENT_VERSION && match[:rest]
|
|
54
|
+
return nil if match[:trace_id] == ZERO_TRACE_ID
|
|
55
|
+
return nil if match[:parent_id] == ZERO_PARENT_ID
|
|
56
|
+
|
|
57
|
+
new(
|
|
58
|
+
trace_id: match[:trace_id],
|
|
59
|
+
parent_id: match[:parent_id],
|
|
60
|
+
flags: match[:flags].to_i(16),
|
|
61
|
+
tracestate: sanitize_tracestate(tracestate)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Nuovo contesto root (nessun traceparent entrante valido). Genera trace-id (16 byte) e parent-id
|
|
66
|
+
# (8 byte) casuali — SecureRandom è fork-safe, così un worker forkato non riusa gli id del padre.
|
|
67
|
+
# `sampled` fissa il flag: un root che apriamo noi traccia di default.
|
|
68
|
+
def generate(sampled: true)
|
|
69
|
+
new(
|
|
70
|
+
trace_id: SecureRandom.hex(16),
|
|
71
|
+
parent_id: SecureRandom.hex(8),
|
|
72
|
+
flags: sampled ? FLAG_SAMPLED : 0,
|
|
73
|
+
tracestate: nil
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Trattiene solo i membri ben formati, nell'ordine originale, fino a 32. Ritorna nil se non ne
|
|
80
|
+
# resta nessuno → così non propaghiamo mai un tracestate spazzatura o sovradimensionato.
|
|
81
|
+
def sanitize_tracestate(tracestate)
|
|
82
|
+
return nil if tracestate.nil?
|
|
83
|
+
|
|
84
|
+
valid = tracestate.to_s.split(",").map(&:strip)
|
|
85
|
+
.select { |member| TRACESTATE_MEMBER.match?(member) }
|
|
86
|
+
.first(TRACESTATE_MAX_MEMBERS)
|
|
87
|
+
valid.empty? ? nil : valid.join(",")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sampled?
|
|
92
|
+
(flags & FLAG_SAMPLED) != 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# traceparent d'uscita, sempre versione 00 (l'unica che sappiamo emettere). I flag sono ri-emessi
|
|
96
|
+
# per intero (i bit riservati vanno propagati as-is), formattati su due cifre esadecimali.
|
|
97
|
+
def traceparent
|
|
98
|
+
format("%s-%s-%s-%02x", CURRENT_VERSION, trace_id, parent_id, flags & 0xff)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Header di propagazione W3C: traceparent (+ tracestate se presente). MAI `baggage`: può contenere
|
|
102
|
+
# contesto interno/PII e non deve varcare il confine (ticket: "baggage sensibile non riceve header").
|
|
103
|
+
def headers
|
|
104
|
+
result = { "traceparent" => traceparent }
|
|
105
|
+
result["tracestate"] = tracestate if tracestate && !tracestate.empty?
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module CloseYourIt
|
|
6
|
+
# CYSK-29 — la telemetria d'uso: registra QUALI simboli girano davvero (route come
|
|
7
|
+
# `Controller#action`, job, chiavi custom letterali) e li flusha in una POST ogni
|
|
8
|
+
# `usage_flush_interval` secondi per processo. Sul percorso caldo: una lookup e un increment.
|
|
9
|
+
#
|
|
10
|
+
# Le regole che rendono il sistema immune alle perdite:
|
|
11
|
+
# - i conteggi sono INDICATIVI, solo `last_seen_at` è portante: un flush perso costa una finestra
|
|
12
|
+
# su un simbolo che si rivede subito dopo;
|
|
13
|
+
# - il registro SI SVUOTA a ogni flush: il tetto (`usage_max_symbols`) limita i simboli distinti
|
|
14
|
+
# visti in una finestra — una grandezza legata al traffico, non alla dimensione del codice;
|
|
15
|
+
# - oltre il tetto il flush porta `truncated: true`, e lato scanner quel flag SQUALIFICA il kind;
|
|
16
|
+
# - nessun sampling, mai: un campionamento su una rotta chiamata 3 volte al mese fabbrica
|
|
17
|
+
# esattamente il falso «mai vista» che il sistema esiste per evitare;
|
|
18
|
+
# - `route` è `Controller#action`, MAI l'URL: niente path, parametri, user, IP.
|
|
19
|
+
class UsageRegistry
|
|
20
|
+
# I simboli vengono da payload di framework o da stringhe letterali: il pattern è il contratto.
|
|
21
|
+
SYMBOL_FORMAT = %r{\A[A-Za-z0-9_:#./-]{1,200}\z}
|
|
22
|
+
KINDS = %w[route job custom].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :timer # esposto per i test (verifica dell'intervallo configurato)
|
|
25
|
+
|
|
26
|
+
def initialize(client:, configuration:)
|
|
27
|
+
@client = client
|
|
28
|
+
@configuration = configuration
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@symbols = {}
|
|
31
|
+
@truncated = false
|
|
32
|
+
@window_started_at = Time.now.utc
|
|
33
|
+
@timer = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def record(kind, symbol)
|
|
37
|
+
return unless @configuration.usage_enabled
|
|
38
|
+
|
|
39
|
+
kind = kind.to_s
|
|
40
|
+
symbol = symbol.to_s
|
|
41
|
+
return unless KINDS.include?(kind) && symbol.match?(SYMBOL_FORMAT)
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
key = [ kind, symbol ]
|
|
45
|
+
entry = @symbols[key]
|
|
46
|
+
if entry
|
|
47
|
+
entry[:count] += 1
|
|
48
|
+
entry[:last_seen_at] = Time.now.utc
|
|
49
|
+
elsif @symbols.size >= @configuration.usage_max_symbols.to_i
|
|
50
|
+
@truncated = true
|
|
51
|
+
else
|
|
52
|
+
@symbols[key] = { count: 1, last_seen_at: Time.now.utc }
|
|
53
|
+
end
|
|
54
|
+
ensure_timer
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def flush
|
|
59
|
+
payload = drain
|
|
60
|
+
return if payload[:symbols].empty? && !payload[:truncated]
|
|
61
|
+
|
|
62
|
+
@client.flush_usage(**payload)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
# Il flush gira sul thread timer: un errore non deve propagare nell'app né uccidere il
|
|
65
|
+
# TimerTask. La semantica portante è last_seen_at: una finestra persa non falsifica niente.
|
|
66
|
+
CloseYourIt.internal_logger.error("CloseYourIt usage registry: #{e.class}: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def shutdown
|
|
70
|
+
@timer&.shutdown
|
|
71
|
+
flush
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def drain
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
symbols = @symbols.map do |(kind, symbol), entry|
|
|
79
|
+
{ kind: kind, symbol: symbol, count: entry[:count], last_seen_at: entry[:last_seen_at].iso8601 }
|
|
80
|
+
end
|
|
81
|
+
payload = { symbols: symbols, truncated: @truncated,
|
|
82
|
+
window_started_at: @window_started_at.iso8601, window_ended_at: Time.now.utc.iso8601 }
|
|
83
|
+
@symbols = {}
|
|
84
|
+
@truncated = false
|
|
85
|
+
@window_started_at = Time.now.utc
|
|
86
|
+
payload
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ensure_timer
|
|
91
|
+
return if @timer
|
|
92
|
+
|
|
93
|
+
interval = @configuration.usage_flush_interval.to_i
|
|
94
|
+
interval = 300 if interval <= 0
|
|
95
|
+
@timer = Concurrent::TimerTask.new(execution_interval: interval) { flush }
|
|
96
|
+
@timer.execute
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/closeyourit/version.rb
CHANGED
data/lib/closeyourit-ruby.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "logger"
|
|
|
5
5
|
require_relative "closeyourit/version"
|
|
6
6
|
require_relative "closeyourit/configuration"
|
|
7
7
|
require_relative "closeyourit/breadcrumb"
|
|
8
|
+
require_relative "closeyourit/trace_context"
|
|
8
9
|
require_relative "closeyourit/scope"
|
|
9
10
|
require_relative "closeyourit/scrubber"
|
|
10
11
|
require_relative "closeyourit/stats"
|
|
@@ -22,6 +23,7 @@ require_relative "closeyourit/performance/request_profile"
|
|
|
22
23
|
require_relative "closeyourit/performance/rollup"
|
|
23
24
|
require_relative "closeyourit/log_device"
|
|
24
25
|
require_relative "closeyourit/log_buffer"
|
|
26
|
+
require_relative "closeyourit/usage_registry"
|
|
25
27
|
require_relative "closeyourit/subscribers/slow_query"
|
|
26
28
|
require_relative "closeyourit/subscribers/request_performance"
|
|
27
29
|
require_relative "closeyourit/subscribers/job_performance"
|
|
@@ -69,6 +71,7 @@ module CloseYourIt
|
|
|
69
71
|
@configuration = Configuration.new
|
|
70
72
|
@client = nil
|
|
71
73
|
@log_buffer = nil
|
|
74
|
+
@usage_registry = nil
|
|
72
75
|
@shutdown_notified = false # nuova sessione: :shutdown potrà essere notificato di nuovo
|
|
73
76
|
yield(@configuration) if block_given?
|
|
74
77
|
@configuration.validate!
|
|
@@ -263,7 +266,8 @@ module CloseYourIt
|
|
|
263
266
|
# da CloseYourIt.init; con `config.trap_signals` viene raggiunto anche su SIGTERM. Idempotente:
|
|
264
267
|
# richiamarlo è sicuro (buffer già vuoto, worker già fermo → no-op).
|
|
265
268
|
def shutdown
|
|
266
|
-
# Ordine critico: prima
|
|
269
|
+
# Ordine critico: prima i buffer (accodano l'ultimo batch nel worker), poi il worker (li drena).
|
|
270
|
+
@usage_registry&.shutdown
|
|
267
271
|
@log_buffer&.shutdown
|
|
268
272
|
@client&.shutdown
|
|
269
273
|
# Riepilogo di fine-vita: l'app riceve lo snapshot dei contatori senza log rumorosi. Emesso una
|
|
@@ -322,6 +326,22 @@ module CloseYourIt
|
|
|
322
326
|
nil
|
|
323
327
|
end
|
|
324
328
|
|
|
329
|
+
# CYSK-29 — il registro della telemetria d'uso (una lookup + increment sul percorso caldo).
|
|
330
|
+
def usage_registry
|
|
331
|
+
ensure_current_process!
|
|
332
|
+
@usage_registry ||= UsageRegistry.new(client: client, configuration: configuration)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# CYSK-29 — dichiara che un pezzo di codice è stato ESEGUITO. `key` deve essere una stringa
|
|
336
|
+
# LETTERALE (mai interpolata con dati): è l'unico modo onesto di rispondere a «questo ramo viene
|
|
337
|
+
# mai preso?». No-op se la gemma non è configurata o usage_enabled è OFF.
|
|
338
|
+
def used(key)
|
|
339
|
+
return nil unless configured? && enabled?
|
|
340
|
+
|
|
341
|
+
usage_registry.record("custom", key)
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
325
345
|
private
|
|
326
346
|
|
|
327
347
|
# Vero se il thread corrente sta eseguendo l'hook diagnostico: le API di telemetria diventano no-op
|
|
@@ -348,6 +368,7 @@ module CloseYourIt
|
|
|
348
368
|
@log_buffer ||= LogBuffer.new(client: client, configuration: configuration)
|
|
349
369
|
end
|
|
350
370
|
|
|
371
|
+
|
|
351
372
|
# Rileva un fork confrontando il PID del processo in cui @client/@log_buffer sono stati materializzati
|
|
352
373
|
# con quello corrente. In un figlio forkato i due oggetti sono ereditati dal padre, ma i loro thread
|
|
353
374
|
# — il worker pool di Client e il TimerTask di LogBuffer — vivono solo nel padre (il fork copia il
|
|
@@ -370,6 +391,7 @@ module CloseYourIt
|
|
|
370
391
|
def discard_inherited_client!
|
|
371
392
|
@client = nil
|
|
372
393
|
@log_buffer = nil
|
|
394
|
+
@usage_registry = nil
|
|
373
395
|
end
|
|
374
396
|
|
|
375
397
|
# I log seguono il master switch del client + il proprio flag dedicato.
|
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.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alessio Bussolari
|
|
@@ -85,7 +85,9 @@ files:
|
|
|
85
85
|
- lib/closeyourit/subscribers/job_performance.rb
|
|
86
86
|
- lib/closeyourit/subscribers/request_performance.rb
|
|
87
87
|
- lib/closeyourit/subscribers/slow_query.rb
|
|
88
|
+
- lib/closeyourit/trace_context.rb
|
|
88
89
|
- lib/closeyourit/transport.rb
|
|
90
|
+
- lib/closeyourit/usage_registry.rb
|
|
89
91
|
- lib/closeyourit/version.rb
|
|
90
92
|
homepage: https://github.com/bussolabs/closeyourit-ruby
|
|
91
93
|
licenses:
|