closeyourit-ruby 0.6.1 → 0.8.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 +141 -3
- data/lib/closeyourit/background_worker.rb +1 -0
- data/lib/closeyourit/client.rb +26 -4
- data/lib/closeyourit/configuration.rb +68 -3
- data/lib/closeyourit/events/job_metric_event.rb +44 -0
- data/lib/closeyourit/log_buffer.rb +6 -0
- data/lib/closeyourit/rails/active_job_extension.rb +60 -4
- data/lib/closeyourit/rails/log_broadcast.rb +9 -2
- data/lib/closeyourit/rails/net_http_patch.rb +50 -0
- data/lib/closeyourit/rails/railtie.rb +29 -3
- data/lib/closeyourit/rails/request_context.rb +23 -3
- data/lib/closeyourit/scope.rb +11 -1
- data/lib/closeyourit/sidekiq/job_metrics_middleware.rb +58 -0
- data/lib/closeyourit/stats.rb +11 -4
- data/lib/closeyourit/subscribers/job_performance.rb +123 -0
- data/lib/closeyourit/subscribers/slow_query.rb +21 -0
- data/lib/closeyourit/trace_context.rb +109 -0
- data/lib/closeyourit/transport.rb +17 -0
- data/lib/closeyourit/version.rb +1 -1
- data/lib/closeyourit-ruby.rb +146 -5
- metadata +19 -1
|
@@ -10,6 +10,8 @@ require_relative "log_broadcast"
|
|
|
10
10
|
require_relative "net_http_patch"
|
|
11
11
|
require_relative "../subscribers/slow_query"
|
|
12
12
|
require_relative "../subscribers/request_performance"
|
|
13
|
+
require_relative "../subscribers/job_performance"
|
|
14
|
+
require_relative "../sidekiq/job_metrics_middleware"
|
|
13
15
|
|
|
14
16
|
module CloseYourIt
|
|
15
17
|
module Rails
|
|
@@ -73,8 +75,9 @@ module CloseYourIt
|
|
|
73
75
|
end
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
# Strumenta
|
|
77
|
-
#
|
|
78
|
+
# Strumenta Net::HTTP: rileva le chiamate esterne lente (solo con detect_performance_issues) e
|
|
79
|
+
# propaga il trace context W3C (solo con propagate_trace_context). Prepend incondizionato — i due
|
|
80
|
+
# opt-in sono valutati per-chiamata nel patch; con entrambi OFF è di fatto no-op (chiama super).
|
|
78
81
|
initializer "closeyourit.instrument_net_http" do
|
|
79
82
|
require "net/http"
|
|
80
83
|
::Net::HTTP.prepend(CloseYourIt::Rails::NetHTTPPatch) unless ::Net::HTTP.ancestors.include?(CloseYourIt::Rails::NetHTTPPatch)
|
|
@@ -87,6 +90,24 @@ module CloseYourIt
|
|
|
87
90
|
end
|
|
88
91
|
end
|
|
89
92
|
|
|
93
|
+
# Misura durata e attesa in coda dei job ActiveJob via notifiche ActiveSupport: `perform_start`
|
|
94
|
+
# dà l'attesa (now - enqueued_at) appena il job parte, `perform` dà la durata dell'esecuzione a
|
|
95
|
+
# fine job. Oltre soglia → metriche slow_job / job_queue_latency. No-op se monitor_jobs è OFF.
|
|
96
|
+
initializer "closeyourit.subscribe_active_job_performance" do
|
|
97
|
+
jobs = CloseYourIt::Subscribers::JobPerformance.new
|
|
98
|
+
|
|
99
|
+
ActiveSupport::Notifications.subscribe("perform_start.active_job") do |*args|
|
|
100
|
+
job = ActiveSupport::Notifications::Event.new(*args).payload[:job]
|
|
101
|
+
jobs.active_job_started(job) if job
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
|
|
105
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
106
|
+
job = event.payload[:job]
|
|
107
|
+
jobs.active_job_performed(job, event.duration) if job
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
90
111
|
# Cattura gli errori HANDLED riportati via Rails.error.report (Rails 7+).
|
|
91
112
|
initializer "closeyourit.error_reporter" do
|
|
92
113
|
if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
@@ -110,11 +131,16 @@ module CloseYourIt
|
|
|
110
131
|
end
|
|
111
132
|
end
|
|
112
133
|
|
|
113
|
-
# Cattura gli errori dei job Sidekiq (solo se
|
|
134
|
+
# Cattura gli errori dei job Sidekiq + misura durata/attesa via server middleware (solo se
|
|
135
|
+
# Sidekiq è presente). Il middleware è no-op effettivo se monitor_jobs è OFF (la decisione vive
|
|
136
|
+
# in JobPerformance#record).
|
|
114
137
|
initializer "closeyourit.sidekiq" do
|
|
115
138
|
if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
116
139
|
::Sidekiq.configure_server do |sidekiq_config|
|
|
117
140
|
sidekiq_config.error_handlers << CloseYourIt::Sidekiq::ErrorHandler.new
|
|
141
|
+
sidekiq_config.server_middleware do |chain|
|
|
142
|
+
chain.add CloseYourIt::Sidekiq::JobMetricsMiddleware
|
|
143
|
+
end
|
|
118
144
|
end
|
|
119
145
|
end
|
|
120
146
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
-
|
|
4
|
+
require_relative "../trace_context"
|
|
5
5
|
|
|
6
6
|
module CloseYourIt
|
|
7
7
|
module Rails
|
|
@@ -23,8 +23,13 @@ module CloseYourIt
|
|
|
23
23
|
|
|
24
24
|
def call(env)
|
|
25
25
|
if enabled?
|
|
26
|
-
#
|
|
27
|
-
|
|
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)
|
|
28
33
|
# Correlazione errore server ↔ session replay: l'id dal cookie finisce sullo scope
|
|
29
34
|
# → contexts.replay.replay_id dell'evento (stesso punto del percorso JS).
|
|
30
35
|
CloseYourIt::Scope.current.replay_session_id = replay_session_id_for(env)
|
|
@@ -47,6 +52,17 @@ module CloseYourIt
|
|
|
47
52
|
false
|
|
48
53
|
end
|
|
49
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
|
+
|
|
50
66
|
# Riusa il request id di Rails/Rack se presente (stessa correlazione dei log applicativi),
|
|
51
67
|
# altrimenti ne genera uno.
|
|
52
68
|
def trace_id_for(env)
|
|
@@ -60,6 +76,10 @@ module CloseYourIt
|
|
|
60
76
|
cookie = env["HTTP_COOKIE"]
|
|
61
77
|
return nil if cookie.nil? || cookie.empty?
|
|
62
78
|
|
|
79
|
+
# `rack/utils` caricato lazy: il middleware gira solo dentro un'app Rack (dove Rack c'è di
|
|
80
|
+
# sicuro), così `require "closeyourit-ruby"` non forza Rack in app non-web (CLI/worker) e la
|
|
81
|
+
# gemma resta installabile senza dichiarare `rack` tra le dipendenze (CYRB-13).
|
|
82
|
+
require "rack/utils"
|
|
63
83
|
Rack::Utils.parse_cookies_header(cookie)[REPLAY_COOKIE].to_s.then { |id| id.empty? ? nil : id }
|
|
64
84
|
end
|
|
65
85
|
|
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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../subscribers/job_performance"
|
|
4
|
+
|
|
5
|
+
module CloseYourIt
|
|
6
|
+
module Sidekiq
|
|
7
|
+
# Server middleware Sidekiq (registrato dal railtie solo se Sidekiq è presente) che misura la durata
|
|
8
|
+
# di esecuzione e l'attesa in coda del job, poi delega a Subscribers::JobPerformance l'emissione
|
|
9
|
+
# delle metriche oltre soglia. Non altera il job: cronometra attorno allo `yield` e ri-solleva
|
|
10
|
+
# qualunque errore invariato (la cattura degli errori è dell'ErrorHandler). La misurazione avviene
|
|
11
|
+
# nell'`ensure`, così è presa anche per i job che sollevano; la telemetria è isolata (un errore nel
|
|
12
|
+
# nostro codice non disturba mai il job ospite). No-op effettivo se `monitor_jobs` è OFF (la
|
|
13
|
+
# decisione vive in #record).
|
|
14
|
+
class JobMetricsMiddleware
|
|
15
|
+
def initialize(subscriber = nil)
|
|
16
|
+
@subscriber = subscriber
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(_worker, job, queue)
|
|
20
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
21
|
+
# L'attesa in coda si conosce all'INIZIO (now - enqueued_at); Sidekiq mette enqueued_at come
|
|
22
|
+
# epoch in secondi. Calcolata prima dello yield per non includere la durata del job.
|
|
23
|
+
latency = Subscribers::JobPerformance.latency_ms(job["enqueued_at"], now: Time.now.utc)
|
|
24
|
+
yield
|
|
25
|
+
ensure
|
|
26
|
+
emit(job, queue, started, latency)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def emit(job, queue, started, latency)
|
|
32
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
33
|
+
subscriber.record(
|
|
34
|
+
job_class: job["wrapped"] || job["class"],
|
|
35
|
+
queue: queue || job["queue"],
|
|
36
|
+
adapter: "sidekiq",
|
|
37
|
+
duration_ms: duration_ms,
|
|
38
|
+
queue_latency_ms: latency,
|
|
39
|
+
attempt: attempt(job),
|
|
40
|
+
trace_id: job["jid"]
|
|
41
|
+
)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
CloseYourIt.internal_logger.error("CloseYourIt job metrics: #{e.class}: #{e.message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def subscriber
|
|
47
|
+
@subscriber ||= Subscribers::JobPerformance.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Numero di esecuzione 1-based. Sidekiq NON imposta `retry_count` al primo run (nil), lo porta a 0
|
|
51
|
+
# al primo retry, 1 al secondo, ... → attempt = retry_count + 2 quando presente, 1 al primo run.
|
|
52
|
+
def attempt(job)
|
|
53
|
+
count = job["retry_count"]
|
|
54
|
+
count.nil? ? 1 : count + 2
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/closeyourit/stats.rb
CHANGED
|
@@ -4,12 +4,14 @@ require "concurrent"
|
|
|
4
4
|
|
|
5
5
|
module CloseYourIt
|
|
6
6
|
# Contatori diagnostici thread-safe del client: quanti eventi sono stati accodati,
|
|
7
|
-
# scartati (coda piena), spediti con successo
|
|
8
|
-
#
|
|
7
|
+
# scartati (coda piena / before_send / sampling), spediti con successo, falliti (rete o status
|
|
8
|
+
# non-2xx) e, tra i falliti, quanti per timeout di rete. Rendono visibili i fallimenti silenziosi
|
|
9
|
+
# del trasporto fire-and-forget. `timeout` è un sotto-conteggio di `failed` (un timeout resta un
|
|
10
|
+
# fallimento d'invio): li teniamo distinti per isolare i problemi di connettività dai non-2xx.
|
|
9
11
|
#
|
|
10
|
-
# CloseYourIt.stats.to_h # => { enqueued: 12, dropped: 0, sent: 11, failed: 1 }
|
|
12
|
+
# CloseYourIt.stats.to_h # => { enqueued: 12, dropped: 0, sent: 11, failed: 1, timeout: 1 }
|
|
11
13
|
class Stats
|
|
12
|
-
COUNTERS = %i[enqueued dropped sent failed].freeze
|
|
14
|
+
COUNTERS = %i[enqueued dropped sent failed timeout].freeze
|
|
13
15
|
|
|
14
16
|
def initialize
|
|
15
17
|
@counters = COUNTERS.to_h { |name| [ name, Concurrent::AtomicFixnum.new(0) ] }
|
|
@@ -29,6 +31,11 @@ module CloseYourIt
|
|
|
29
31
|
@counters.transform_values(&:value)
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Fotografia thread-safe dei contatori (ogni valore letto atomicamente). È lo stesso Hash di
|
|
35
|
+
# `to_h`, con un nome esplicito per il caso d'uso "leggo la diagnostica locale" (CYRB-12): pura
|
|
36
|
+
# lettura in-memory, non invia mai telemetria.
|
|
37
|
+
alias_method :snapshot, :to_h
|
|
38
|
+
|
|
32
39
|
def reset!
|
|
33
40
|
@counters.each_value { |counter| counter.value = 0 }
|
|
34
41
|
self
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../events/job_metric_event"
|
|
5
|
+
|
|
6
|
+
module CloseYourIt
|
|
7
|
+
module Subscribers
|
|
8
|
+
# Misura durata di esecuzione e attesa in coda (queue latency) dei background job — ActiveJob e
|
|
9
|
+
# Sidekiq — e, oltre le soglie configurate, emette metriche performance_issue (subtype `slow_job`
|
|
10
|
+
# e `job_queue_latency`). Logica PURA e SENZA STATO condiviso: tutto arriva per parametri, quindi
|
|
11
|
+
# job concorrenti sullo stesso thread/processo non si contaminano. Il wiring ad
|
|
12
|
+
# ActiveSupport::Notifications e al middleware Sidekiq vive altrove (Railtie / JobMetricsMiddleware).
|
|
13
|
+
# Rispetta il master switch `monitor_jobs`, le soglie e il `jobs_sample_rate`.
|
|
14
|
+
class JobPerformance
|
|
15
|
+
def initialize(configuration = nil)
|
|
16
|
+
@configuration = configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Punto unico di decisione: dai valori misurati (durata e/o attesa) costruisce 0..2 metriche,
|
|
20
|
+
# applica le soglie (stretto `>`, così X non genera rumore e X+1 sì) e il sampling, poi le spedisce
|
|
21
|
+
# fire-and-forget. `duration_ms` e `queue_latency_ms` sono opzionali: ActiveJob li fornisce da due
|
|
22
|
+
# hook distinti (perform_start → attesa, perform → durata), Sidekiq entrambi in una sola chiamata.
|
|
23
|
+
# Il sampling è applicato SOLO ai candidati già oltre soglia (i job normali non consumano né
|
|
24
|
+
# generano nulla).
|
|
25
|
+
def record(job_class:, queue: nil, adapter: nil, duration_ms: nil, queue_latency_ms: nil,
|
|
26
|
+
attempt: nil, trace_id: nil)
|
|
27
|
+
config = configuration
|
|
28
|
+
return unless config.monitor_jobs
|
|
29
|
+
|
|
30
|
+
common = { job_class: job_class, queue: queue, adapter: adapter, attempt: attempt, trace_id: trace_id }
|
|
31
|
+
events = []
|
|
32
|
+
events << build(config, "slow_job", duration_ms, common) if slow?(config, duration_ms)
|
|
33
|
+
events << build(config, "job_queue_latency", queue_latency_ms, common) if waited?(config, queue_latency_ms)
|
|
34
|
+
return if events.empty?
|
|
35
|
+
return unless sampled?(config)
|
|
36
|
+
|
|
37
|
+
events.each { |event| CloseYourIt.capture_event(event) }
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Hook `perform_start.active_job`: l'attesa in coda è nota appena il job parte (now - enqueued_at).
|
|
42
|
+
def active_job_started(job, now: Time.now.utc)
|
|
43
|
+
record(
|
|
44
|
+
job_class: job.class.name,
|
|
45
|
+
queue: (job.queue_name if job.respond_to?(:queue_name)),
|
|
46
|
+
adapter: "active_job",
|
|
47
|
+
queue_latency_ms: self.class.latency_ms(enqueued_at(job), now: now),
|
|
48
|
+
attempt: (job.executions if job.respond_to?(:executions)),
|
|
49
|
+
trace_id: (job.job_id if job.respond_to?(:job_id))
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Hook `perform.active_job`: a fine esecuzione la durata è `event.duration` (ms).
|
|
54
|
+
def active_job_performed(job, duration_ms)
|
|
55
|
+
record(
|
|
56
|
+
job_class: job.class.name,
|
|
57
|
+
queue: (job.queue_name if job.respond_to?(:queue_name)),
|
|
58
|
+
adapter: "active_job",
|
|
59
|
+
duration_ms: duration_ms,
|
|
60
|
+
attempt: (job.executions if job.respond_to?(:executions)),
|
|
61
|
+
trace_id: (job.job_id if job.respond_to?(:job_id))
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Normalizza `enqueued_at` (Time, epoch Numerico in secondi come Sidekiq, o String ISO8601) in
|
|
66
|
+
# attesa (ms) rispetto a `now`. nil o non parsabile → nil: nessuna metrica di attesa (adapter che
|
|
67
|
+
# non popola l'istante di enqueue). Clamp a 0 se negativa (clock skew, enqueue "nel futuro"): lo
|
|
68
|
+
# schema di ingest esige `duration_ms >= 0`.
|
|
69
|
+
def self.latency_ms(enqueued_at, now:)
|
|
70
|
+
started = to_time(enqueued_at)
|
|
71
|
+
return nil if started.nil?
|
|
72
|
+
|
|
73
|
+
ms = (now - started) * 1000.0
|
|
74
|
+
ms.negative? ? 0.0 : ms
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.to_time(value)
|
|
78
|
+
case value
|
|
79
|
+
when Time then value
|
|
80
|
+
when Numeric then Time.at(value)
|
|
81
|
+
when String then parse_time(value)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.parse_time(value)
|
|
86
|
+
Time.parse(value)
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def enqueued_at(job)
|
|
94
|
+
job.enqueued_at if job.respond_to?(:enqueued_at)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def configuration
|
|
98
|
+
@configuration || CloseYourIt.configuration
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def slow?(config, duration_ms)
|
|
102
|
+
duration_ms && config.slow_job_threshold_ms && duration_ms > config.slow_job_threshold_ms
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def waited?(config, latency_ms)
|
|
106
|
+
latency_ms && config.job_queue_latency_threshold_ms &&
|
|
107
|
+
latency_ms > config.job_queue_latency_threshold_ms
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def sampled?(config)
|
|
111
|
+
rate = config.jobs_sample_rate.to_f
|
|
112
|
+
return true if rate >= 1.0
|
|
113
|
+
return false if rate <= 0.0
|
|
114
|
+
|
|
115
|
+
Random.rand < rate
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build(config, subtype, duration_ms, common)
|
|
119
|
+
JobMetricEvent.new(common.merge(subtype: subtype, duration_ms: duration_ms), config)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -21,6 +21,7 @@ module CloseYourIt
|
|
|
21
21
|
config = @configuration || CloseYourIt.configuration
|
|
22
22
|
return if ignored_name?(name)
|
|
23
23
|
return if duration_ms < config.slow_query_threshold_ms
|
|
24
|
+
return if excluded_sql?(config, sql)
|
|
24
25
|
|
|
25
26
|
event = SlowQueryEvent.new(
|
|
26
27
|
{ name: name, sql: sql, cached: cached, connection: connection,
|
|
@@ -77,6 +78,26 @@ module CloseYourIt
|
|
|
77
78
|
def ignored_name?(name)
|
|
78
79
|
name.nil? || IGNORED_NAMES.include?(name)
|
|
79
80
|
end
|
|
81
|
+
|
|
82
|
+
# Query esclusa dalla MISURA dei rallentamenti (config.excluded_query_patterns). Il filtro sta
|
|
83
|
+
# solo qui, non in #breadcrumb né in #profile:
|
|
84
|
+
#
|
|
85
|
+
# - breadcrumb: la cronologia "quali query prima del crash" ha valore diagnostico anche quando
|
|
86
|
+
# la query è del framework — nasconderla renderebbe la sequenza incompleta e bugiarda;
|
|
87
|
+
# - profile: è per-richiesta e serve la detection N+1, dove una tabella di servizio letta molte
|
|
88
|
+
# volte è essa stessa un sintomo da vedere.
|
|
89
|
+
#
|
|
90
|
+
# Un rallentamento va misurato se qualcuno può intervenire; una breadcrumb va tenuta se aiuta a
|
|
91
|
+
# capire. Sono due domande diverse, e questa lista risponde solo alla prima.
|
|
92
|
+
def excluded_sql?(config, sql)
|
|
93
|
+
return false if sql.nil?
|
|
94
|
+
|
|
95
|
+
patterns = config.excluded_query_patterns
|
|
96
|
+
return false if patterns.empty?
|
|
97
|
+
|
|
98
|
+
text = sql.to_s
|
|
99
|
+
patterns.any? { |pattern| pattern.match?(text) }
|
|
100
|
+
end
|
|
80
101
|
end
|
|
81
102
|
end
|
|
82
103
|
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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
|
+
require "timeout"
|
|
6
7
|
|
|
7
8
|
module CloseYourIt
|
|
8
9
|
# Spedisce un payload a un path di ingest (errori → /events, metriche → /metrics) via HTTP POST
|
|
@@ -14,6 +15,10 @@ module CloseYourIt
|
|
|
14
15
|
# Ri-POSTiamo a Location preservando metodo + body, così l'evento non si perde in silenzio.
|
|
15
16
|
MAX_REDIRECTS = 2
|
|
16
17
|
|
|
18
|
+
# Un timeout di rete (apertura o lettura) è un fallimento d'invio speciale: lo isoliamo dai non-2xx
|
|
19
|
+
# e dagli altri errori di rete perché segnala tipicamente problemi di connettività (CYRB-12).
|
|
20
|
+
TIMEOUT_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, Timeout::Error ].freeze
|
|
21
|
+
|
|
17
22
|
def initialize(configuration)
|
|
18
23
|
@configuration = configuration
|
|
19
24
|
end
|
|
@@ -22,19 +27,31 @@ module CloseYourIt
|
|
|
22
27
|
response = post(payload, path)
|
|
23
28
|
if response.is_a?(Net::HTTPSuccess)
|
|
24
29
|
CloseYourIt.stats.increment(:sent)
|
|
30
|
+
CloseYourIt.notify_diagnostic(:send, status: response.code.to_i)
|
|
25
31
|
else
|
|
26
32
|
CloseYourIt.stats.increment(:failed)
|
|
27
33
|
CloseYourIt.internal_logger.warn("CloseYourIt transport: HTTP #{response.code}#{error_detail(response)} su #{path}")
|
|
34
|
+
CloseYourIt.notify_diagnostic(:drop, reason: :response, status: response.code.to_i)
|
|
28
35
|
end
|
|
29
36
|
response
|
|
30
37
|
rescue StandardError => e
|
|
31
38
|
CloseYourIt.stats.increment(:failed)
|
|
32
39
|
CloseYourIt.internal_logger.error("CloseYourIt transport: #{e.class}: #{e.message}")
|
|
40
|
+
if timeout_error?(e)
|
|
41
|
+
CloseYourIt.stats.increment(:timeout)
|
|
42
|
+
CloseYourIt.notify_diagnostic(:timeout, error: e.class.name)
|
|
43
|
+
else
|
|
44
|
+
CloseYourIt.notify_diagnostic(:drop, reason: :network, error: e.class.name)
|
|
45
|
+
end
|
|
33
46
|
nil
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
private
|
|
37
50
|
|
|
51
|
+
def timeout_error?(error)
|
|
52
|
+
TIMEOUT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
53
|
+
end
|
|
54
|
+
|
|
38
55
|
def post(payload, path)
|
|
39
56
|
body = JSON.generate(payload)
|
|
40
57
|
origin = URI.parse("#{base_url}#{path}")
|
data/lib/closeyourit/version.rb
CHANGED