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.
@@ -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 le chiamate HTTP esterne (Net::HTTP) per rilevare quelle lente nella finestra della
77
- # richiesta. Il patch è no-op (chiama super) se la detection è OFF → overhead trascurabile.
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 Sidekiq è presente).
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
- require "rack/utils"
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
- # trace_id sempre (correlazione log↔errori), anche con capture_request OFF.
27
- CloseYourIt::Scope.current.trace_id = trace_id_for(env)
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
 
@@ -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
@@ -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 o falliti (rete o status non-2xx).
8
- # Servono a rendere visibili i fallimenti silenziosi del trasporto fire-and-forget.
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}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.6.1"
4
+ VERSION = "0.8.0"
5
5
  end