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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '083ced8226884d6ceed86ae4da2a17cb7f9bfdfa5f3cd6073ac09268c4545d6e'
4
- data.tar.gz: 75b18abef8b8c45ae14647c4bc102276286a2c71c1056d37274d0fe350f20555
3
+ metadata.gz: a23acf258deb222638d8d9bd85f57ffc6fbf36429f9c87b50896814b0245e2e3
4
+ data.tar.gz: 7e0cb657fde07728331a131932242fb58685929af5ac88ffc7bfdf707ba3e301
5
5
  SHA512:
6
- metadata.gz: c9285e453091b8f89bc360e1a274fc72fa7b312cd2fd2ac7f8dddb0636a7a49e6f7782f0af5b8e2cb20275e841566731fe48174dddf6f502cb9eb92646bae216
7
- data.tar.gz: 1bcd7478c0d2d5572c0fa761e0b16a6bacffef89bd227a36fae117cea468fdd538bf9f011d839fa378a29871a1b2e8efdfc3ef2c467df684096eff585206a7d3
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
 
@@ -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, poi ri-solleva. La logica vive in `.monitor` per essere
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; cattura l'errore (handled:false) e
16
- # ri-solleva; resetta lo scope a fine job (no bleed tra job sullo stesso thread).
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
- CloseYourIt.capture_exception(e, handled: false)
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 le chiamate HTTP esterne (Net::HTTP) per rilevare quelle lente nella finestra della
79
- # richiesta. Il patch è no-op (chiama super) se la detection è OFF → overhead trascurabile.
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
- # trace_id sempre (correlazione log↔errori), anche con capture_request OFF.
26
- 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)
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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloseYourIt
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.2"
5
5
  end
@@ -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 il buffer (accoda l'ultimo batch nel worker), poi il worker (lo drena).
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.7.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: