closeyourit-ruby 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0d841fba2147566f662dce067e2d41c44d44cf6d402fb929fc3c73eb9a6af4a1
4
+ data.tar.gz: cf7b435c2c9a336f107930ded5fc2ad197032a5d62eeca07f25594775a111bcc
5
+ SHA512:
6
+ metadata.gz: a5172771940399ca5f3f2706e35d3c72d16942b79e0b2bbb89f0dfa184e4a66d45f7644704d45b6fb8f62f645b01bc84e75d7abe1d8a08ae0fced77e87781dcf
7
+ data.tar.gz: dc9b5cc1387ecfb81b70a58815c0f79f7e68f1aeda98c2d3af14d0862f2dd28878a6803a1193e2737c4d8990b833c2aadf0d4461b393a26810957a1b6f0749d0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alessio Bussolari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # closeyourit-ruby
2
+
3
+ Client di telemetria per [CloseYourIt](../closeyourit-rails). Una gemma che il tuo progetto Rails
4
+ installa per inviare a CloseYourIt:
5
+
6
+ - **Eccezioni** → in **formato evento Sentry**, sul path Bearer `/api/v1/projects/:id/events`. Finiscono
7
+ nel error-tracker (raggruppate, triage, promote-to-ticket) come quelle di un SDK Sentry.
8
+ - **Query/metodi lenti** → sul path `/api/v1/projects/:id/metrics` (pipeline metriche dedicata,
9
+ raggruppate per signature con aggregati di durata) — ciò che Sentry/GlitchTip non danno bene.
10
+
11
+ È il **client primario** di CloseYourIt: replica e migliora ciò che fa un SDK Sentry (request context,
12
+ user/tag/contesto, breadcrumbs, errori nei background job, errori handled, sampling, messaggi) senza
13
+ dipendere dagli SDK Sentry.
14
+
15
+ Caratteristiche:
16
+ - **Contesto ricco** sull'errore: request HTTP (method/url/header), user/tag/contesto custom, breadcrumbs
17
+ (cronologia query offuscate prima del crash).
18
+ - Cattura **automatica** di: eccezioni non gestite (Rack), errori in **ActiveJob/Sidekiq** (oggi persi),
19
+ errori **handled** (`Rails.error.report`), query/metodi lenti.
20
+ - `capture_message`, **sampling** (`sample_rate`), ignore per **Regexp**, release detection automatica.
21
+ - Invio **fire-and-forget**: thread pool in background, non blocca la request, **non crasha mai** l'app.
22
+ - **No-op** se non configurata (sicura in sviluppo/test).
23
+ - **Privacy-by-default**: niente PII a meno di abilitarla esplicitamente.
24
+
25
+ > Design e contratto dati in [`PDR.md`](PDR.md).
26
+
27
+ ## Installazione
28
+
29
+ Uso interno → install via git o path (no RubyGems):
30
+
31
+ ```ruby
32
+ # Gemfile del progetto da monitorare
33
+ gem "closeyourit-ruby", git: "https://github.com/bussolabs/closeyourit-ruby"
34
+ # in sviluppo locale:
35
+ # gem "closeyourit-ruby", path: "../closeyourit-ruby"
36
+ ```
37
+
38
+ ## Ottenere credenziali
39
+
40
+ In CloseYourIt, area **Member → Project → tokens**, crea un token: ottieni il **Bearer secret**
41
+ (mostrato una volta) e l'**UUID del progetto**. Servono entrambi alla gemma.
42
+
43
+ ## Configurazione
44
+
45
+ ```ruby
46
+ # config/initializers/closeyourit.rb
47
+ CloseYourIt.init do |c|
48
+ c.endpoint_url = ENV["CLOSEYOURIT_ENDPOINT_URL"] # es. https://closeyour.it
49
+ c.token = ENV["CLOSEYOURIT_TOKEN"] # Bearer secret del Projects::Token
50
+ c.project_id = ENV["CLOSEYOURIT_PROJECT_ID"] # UUID del progetto su CloseYourIt
51
+ c.environment = Rails.env
52
+ end
53
+ ```
54
+
55
+ **Senza `endpoint_url` / `token` / `project_id` la gemma è no-op** (nessun invio, nessun overhead). In
56
+ `production` un `endpoint_url` `http://` viene rifiutato (no-op + warning): il token viaggerebbe in chiaro.
57
+
58
+ ### Opzioni
59
+
60
+ | Opzione | Default | Descrizione |
61
+ |---|---|---|
62
+ | `endpoint_url` | `ENV["CLOSEYOURIT_ENDPOINT_URL"]` | URL base (la gemma appende i path per-progetto) |
63
+ | `token` | `ENV["CLOSEYOURIT_TOKEN"]` | Bearer secret del progetto |
64
+ | `project_id` | `ENV["CLOSEYOURIT_PROJECT_ID"]` | UUID progetto (nel path di ingest) |
65
+ | `release` | `ENV["CLOSEYOURIT_RELEASE"]` | Versione riportata negli errori (opzionale) |
66
+ | `environment` | `Rails.env` / `RACK_ENV` / `"development"` | Ambiente riportato negli eventi |
67
+ | `excluded_exceptions` | `RoutingError`, `RecordNotFound` | Eccezioni da NON inviare — **String** (nome classe) o **Regexp** (match su nome/messaggio) |
68
+ | `before_send` | `nil` | `->(payload) { ... }` — scrub finale; ritorna payload o `nil` per scartare |
69
+ | `sample_rate` | `1.0` | Frazione di errori/messaggi inviata (`1.0` tutto, `0.0` niente) |
70
+ | `async_threads` | `cpu/2` | Thread di invio; `0` = sincrono (test) |
71
+ | `slow_query_threshold_ms` | `100` | Soglia query lente |
72
+ | `slow_method_threshold_ms` | `200` | Soglia metodi lenti |
73
+ | `capture_request` | `true` | Cattura il contesto HTTP della richiesta (method/url/header allowlist) |
74
+ | `request_header_allowlist` | `Accept`, `Content-Type`, `User-Agent`, `Referer` | Header inviati (mai Authorization/Cookie) |
75
+ | `breadcrumbs_enabled` | `true` | Cronologia (query offuscate + `add_breadcrumb`) allegata all'errore |
76
+ | `max_breadcrumbs` | `100` | Dimensione max del ring buffer breadcrumbs |
77
+ | `capture_handled_errors` | `true` | Cattura gli errori riportati via `Rails.error.report` |
78
+ | `report_active_job_errors` | `true` | Cattura gli errori dei job ActiveJob/Solid Queue/Sidekiq |
79
+ | `send_pii` | `false` | Master switch PII |
80
+ | `obfuscate_sql` | `true` | Maschera i literal nello SQL |
81
+ | `filter_parameters` | `[]` | Chiavi extra da redarre (mergiate con quelle di Rails) |
82
+ | `scrub_message_patterns` | `[]` | Regexp da redarre dai messaggi d'eccezione |
83
+
84
+ ## Cosa cattura
85
+
86
+ ### Eccezioni (automatico, con Rails) → error-tracker
87
+
88
+ Il Railtie inserisce un Rack middleware che cattura le eccezioni non gestite, le invia come **evento
89
+ Sentry** (`exception.values[]`, `level`, `event_id`, stacktrace) e le **ri-solleva** (l'app continua a
90
+ gestirle come prima). Cattura manuale ovunque:
91
+
92
+ ```ruby
93
+ begin
94
+ rischioso!
95
+ rescue => e
96
+ CloseYourIt.capture_exception(e)
97
+ raise
98
+ end
99
+ ```
100
+
101
+ ### Request context (automatico, con Rails)
102
+
103
+ Un Rack middleware allega a ogni evento il contesto HTTP della richiesta: **method**, **url** (senza
104
+ query string) e gli **header dell'allowlist** (`request_header_allowlist`, mai Authorization/Cookie).
105
+ Query string e IP solo con `send_pii`. Lo scope è resettato a fine richiesta (niente bleed tra request).
106
+
107
+ ### Background job + errori handled (automatico, con Rails)
108
+
109
+ - **ActiveJob / Solid Queue / Sidekiq**: gli errori dei job (prima persi) vengono catturati con tag
110
+ `job.class`/`job.queue` e contesto del job, poi ri-sollevati.
111
+ - **`Rails.error.report`** (ActiveSupport ErrorReporter): gli errori *handled* vengono inviati con
112
+ `mechanism.handled = true` e il `level` mappato dalla severity. La dedup garantisce un solo invio anche
113
+ se la stessa eccezione passa da più punti (Rack + job + reporter).
114
+
115
+ ### Contesto, breadcrumbs e messaggi (manuale)
116
+
117
+ ```ruby
118
+ CloseYourIt.set_user(id: account.id) # solo id; email/ip solo se send_pii
119
+ CloseYourIt.set_tag(:tenant, current_tenant.slug)
120
+ CloseYourIt.set_context(:billing, { plan: "pro" })
121
+ CloseYourIt.set_extra(:cart_size, cart.size)
122
+ CloseYourIt.configure_scope { |s| s.set_tag(:area, "checkout") }
123
+
124
+ CloseYourIt.add_breadcrumb(message: "coupon applicato", category: "ui") # cronologia pre-crash
125
+ CloseYourIt.capture_message("cache miss storm", level: "warning") # messaggio diagnostico
126
+ ```
127
+
128
+ Lo scope (user/tag/contesto/breadcrumbs) è **per-richiesta/job** e viene allegato automaticamente
129
+ all'evento d'errore catturato nello stesso contesto di esecuzione.
130
+
131
+ ### Query lente (automatico, con Rails) → metriche
132
+
133
+ Il Railtie si iscrive a `sql.active_record`: ogni query oltre `slow_query_threshold_ms` (esclusi
134
+ `SCHEMA`/`CACHE`) viene inviata come `slow_query` alla pipeline metriche. Lo SQL è **offuscato** (bind esclusi).
135
+
136
+ ### Metodi lenti → metriche
137
+
138
+ ```ruby
139
+ # Blocco ad-hoc
140
+ CloseYourIt.measure("checkout.total") do
141
+ calcolo_pesante
142
+ end
143
+
144
+ # Macro su un metodo (wrap automatico, firma e valore di ritorno invariati)
145
+ class Report
146
+ include CloseYourIt::Monitor
147
+ def generate(...) = ...
148
+ monitor :generate
149
+ end
150
+ ```
151
+
152
+ Viene inviato un `slow_method` solo se la durata supera `slow_method_threshold_ms`. **Gli argomenti
153
+ del metodo non vengono mai inviati** (solo label, durata, file:riga).
154
+
155
+ ## Privacy & PII
156
+
157
+ Privacy-by-default (`send_pii = false`). In sintesi:
158
+
159
+ - **SQL**: inviato il template, **mai** i bind values; `obfuscate_sql` maschera anche i literal inline.
160
+ - **Chiavi sensibili** (`password`, `token`, `authorization`, `cookie`, `secret`, `api_key`, `csrf`,
161
+ `credit_card`, `cvv`, `ssn`, `iban`, …) → `[FILTERED]`; estendibili con `filter_parameters`.
162
+ - **Messaggi d'eccezione**: inviati per il debug, ma redigibili con `scrub_message_patterns` /
163
+ `before_send`.
164
+ - **Mai inviati**: variabili locali dei frame, argomenti dei metodi, IP/cookie/Authorization, token (che
165
+ viaggia solo nell'header su HTTPS).
166
+
167
+ > Rischio residuo: `exception.value` e i nomi tabella/colonna nello SQL possono contenere dati di
168
+ > dominio. Usa `before_send`/`scrub_message_patterns` per azzerarli. Dettaglio in [`PDR.md` §9](PDR.md).
169
+
170
+ ## Sviluppo
171
+
172
+ ```bash
173
+ bundle install
174
+ bundle exec rspec # test (WebMock, niente rete reale)
175
+ bundle exec rubocop # lint (omakase)
176
+ COVERAGE_ENFORCE=1 bundle exec rspec # gate coverage ≥90% line
177
+ ```
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module CloseYourIt
6
+ # Esegue l'invio fire-and-forget. Con `threads == 0` esegue sincrono (test/dev);
7
+ # altrimenti usa una thread-pool con coda bounded e `fallback_policy: :discard`
8
+ # (se la coda è piena l'evento si perde, mai backpressure sulla request).
9
+ class BackgroundWorker
10
+ attr_reader :executor
11
+
12
+ def initialize(threads:, max_queue: 30)
13
+ @executor = build_executor(threads.to_i, max_queue)
14
+ end
15
+
16
+ def perform(&block)
17
+ @executor.post do
18
+ block.call
19
+ rescue Exception => e # rubocop:disable Lint/RescueException
20
+ # Mai propagare: la telemetria non deve poter crashare l'app ospite.
21
+ CloseYourIt.logger.error("CloseYourIt background worker: #{e.class}: #{e.message}")
22
+ end
23
+ end
24
+
25
+ def shutdown(timeout = 1)
26
+ return unless @executor.respond_to?(:shutdown)
27
+
28
+ @executor.shutdown
29
+ @executor.wait_for_termination(timeout)
30
+ end
31
+
32
+ private
33
+
34
+ def build_executor(threads, max_queue)
35
+ return Concurrent::ImmediateExecutor.new if threads <= 0
36
+
37
+ Concurrent::ThreadPoolExecutor.new(
38
+ min_threads: 0,
39
+ max_threads: threads,
40
+ max_queue: max_queue,
41
+ fallback_policy: :discard
42
+ )
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module CloseYourIt
6
+ # Singola briciola di contesto (query, navigazione, evento custom) precedente a un errore.
7
+ # Forma evento Sentry (`breadcrumbs.values[]`). Il `data` è già scrubato a monte (module API).
8
+ class Breadcrumb
9
+ def initialize(message: nil, category: nil, type: "default", level: "info", data: {}, timestamp: nil)
10
+ @timestamp = timestamp || Time.now.utc.iso8601
11
+ @type = type
12
+ @category = category
13
+ @level = level
14
+ @message = message
15
+ @data = data
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ "timestamp" => @timestamp,
21
+ "type" => @type,
22
+ "category" => @category,
23
+ "level" => @level,
24
+ "message" => @message,
25
+ "data" => presence(@data)
26
+ }.reject { |_key, value| value.nil? }
27
+ end
28
+
29
+ private
30
+
31
+ def presence(value)
32
+ value.nil? || value.empty? ? nil : value
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ # Ring buffer limitato di breadcrumb. Vive nello Scope (un buffer per execution-context),
5
+ # scritto solo dal thread proprietario → niente mutex. Oltre `max_size` droppa il più vecchio.
6
+ class BreadcrumbBuffer
7
+ def initialize(max_size)
8
+ @max_size = max_size.to_i
9
+ @items = []
10
+ end
11
+
12
+ def add(breadcrumb)
13
+ return self unless @max_size.positive?
14
+
15
+ @items.shift while @items.size >= @max_size
16
+ @items << breadcrumb
17
+ self
18
+ end
19
+
20
+ def to_a
21
+ @items.map(&:to_h)
22
+ end
23
+
24
+ def empty?
25
+ @items.empty?
26
+ end
27
+
28
+ def size
29
+ @items.size
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ # Compone Transport + BackgroundWorker: applica `before_send` e dispatcha
5
+ # l'invio in modo fire-and-forget.
6
+ class Client
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ @transport = Transport.new(configuration)
10
+ @worker = BackgroundWorker.new(
11
+ threads: configuration.async_threads,
12
+ max_queue: configuration.background_worker_max_queue
13
+ )
14
+ end
15
+
16
+ def capture_event(event)
17
+ payload = event.to_h
18
+ payload = @configuration.before_send.call(payload) if @configuration.before_send
19
+ return nil if payload.nil?
20
+
21
+ path = event.ingest_path(@configuration.project_id)
22
+ @worker.perform { @transport.send_event(payload, path: path) }
23
+ payload
24
+ end
25
+
26
+ def shutdown
27
+ @worker.shutdown
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "concurrent"
5
+
6
+ module CloseYourIt
7
+ # Tiene tutte le opzioni del client. Costruita da `CloseYourIt.init { |c| ... }`.
8
+ # Senza `endpoint_url`/`token`/`project_id` (o con `http://` in produzione) il client è no-op.
9
+ class Configuration
10
+ DEFAULT_EXCLUDED_EXCEPTIONS = %w[
11
+ ActionController::RoutingError
12
+ ActiveRecord::RecordNotFound
13
+ ].freeze
14
+
15
+ # Header HTTP catturati nel contesto request (mai Authorization/Cookie → niente PII/segreti).
16
+ DEFAULT_REQUEST_HEADER_ALLOWLIST = %w[Accept Content-Type User-Agent Referer].freeze
17
+
18
+ attr_accessor :endpoint_url, :token, :project_id, :environment, :before_send,
19
+ :async_threads, :background_worker_max_queue,
20
+ :slow_query_threshold_ms, :slow_method_threshold_ms,
21
+ :send_pii, :obfuscate_sql, :send_server_name,
22
+ :capture_query_bindings, :capture_method_arguments,
23
+ :capture_request, :request_header_allowlist,
24
+ :breadcrumbs_enabled, :max_breadcrumbs, :sample_rate,
25
+ :capture_handled_errors, :report_active_job_errors
26
+ attr_writer :release
27
+ attr_reader :excluded_exceptions, :filter_parameters, :scrub_message_patterns
28
+
29
+ def initialize
30
+ @endpoint_url = ENV.fetch("CLOSEYOURIT_ENDPOINT_URL", nil)
31
+ @token = ENV.fetch("CLOSEYOURIT_TOKEN", nil)
32
+ @project_id = ENV.fetch("CLOSEYOURIT_PROJECT_ID", nil)
33
+ @release = ENV.fetch("CLOSEYOURIT_RELEASE", nil)
34
+ @environment = ENV.fetch("CLOSEYOURIT_ENVIRONMENT") { detect_environment }
35
+
36
+ @excluded_exceptions = DEFAULT_EXCLUDED_EXCEPTIONS.dup
37
+ @before_send = nil
38
+
39
+ @async_threads = default_threads
40
+ @background_worker_max_queue = 30
41
+
42
+ @slow_query_threshold_ms = 100
43
+ @slow_method_threshold_ms = 200
44
+
45
+ @send_pii = false
46
+ @obfuscate_sql = true
47
+ @send_server_name = true
48
+
49
+ # Contesto HTTP della richiesta (method/url/header allowlist). Body/query/IP solo con send_pii.
50
+ @capture_request = true
51
+ @request_header_allowlist = DEFAULT_REQUEST_HEADER_ALLOWLIST.dup
52
+
53
+ # Breadcrumbs: cronologia (query offuscate, eventi custom) allegata all'errore.
54
+ @breadcrumbs_enabled = true
55
+ @max_breadcrumbs = 100
56
+
57
+ # Sampling probabilistico di errori/messaggi (1.0 = invia tutto, 0.0 = niente).
58
+ @sample_rate = 1.0
59
+
60
+ # Cattura errori handled (Rails.error.report) e degli ActiveJob/Sidekiq (oggi persi).
61
+ @capture_handled_errors = true
62
+ @report_active_job_errors = true
63
+
64
+ # Cattura valori dei parametri — opt-in, default OFF (privacy). I bind/argomenti possono contenere PII.
65
+ @capture_query_bindings = false
66
+ @capture_method_arguments = false
67
+
68
+ @filter_parameters = []
69
+ @scrub_message_patterns = []
70
+ end
71
+
72
+ # Classi/stringhe → nome (String); i Regexp restano Regexp (match per pattern su nome/messaggio).
73
+ def excluded_exceptions=(list)
74
+ @excluded_exceptions = Array(list).map { |item| item.is_a?(Regexp) ? item : item.to_s }
75
+ end
76
+
77
+ def filter_parameters=(list)
78
+ @filter_parameters = Array(list)
79
+ end
80
+
81
+ def scrub_message_patterns=(list)
82
+ @scrub_message_patterns = Array(list)
83
+ end
84
+
85
+ def production?
86
+ environment.to_s == "production"
87
+ end
88
+
89
+ # Il client invia solo con credenziali complete (endpoint + token + project_id) e trasporto
90
+ # sicuro (http:// ammesso fuori produzione).
91
+ def enabled?
92
+ return false if blank?(endpoint_url) || blank?(token) || blank?(project_id)
93
+ return false if insecure_endpoint? && production?
94
+
95
+ true
96
+ end
97
+
98
+ # Logga i warning di configurazione (es. endpoint http://). Chiamata da `CloseYourIt.init`.
99
+ def validate!
100
+ CloseYourIt.logger.warn(insecure_endpoint_message) if insecure_endpoint?
101
+ self
102
+ end
103
+
104
+ # Release effettiva: quella impostata, altrimenti auto-rilevata (ENV di deploy/CI o git).
105
+ def release
106
+ return @release unless @release.nil?
107
+
108
+ @release = detect_release
109
+ end
110
+
111
+ # Auto-rilevamento release dalle env di deploy/CI o dal git short SHA. Mai solleva.
112
+ def detect_release
113
+ ENV["KAMAL_VERSION"] ||
114
+ ENV["GIT_SHA"] ||
115
+ ENV["GIT_REVISION"] ||
116
+ ENV["SOURCE_VERSION"] ||
117
+ ENV["HEROKU_SLUG_COMMIT"] ||
118
+ git_revision
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
123
+ private
124
+
125
+ # `.git` è una directory in un checkout normale, un file in un worktree → File.directory?
126
+ # è false nei worktree, così i test non lanciano subprocess git (deterministico).
127
+ def git_revision
128
+ return nil unless File.directory?(".git")
129
+
130
+ sha = `git rev-parse --short HEAD 2>/dev/null`.strip
131
+ sha.empty? ? nil : sha
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def insecure_endpoint?
137
+ uri = parsed_endpoint
138
+ !uri.nil? && uri.scheme != "https"
139
+ end
140
+
141
+ def insecure_endpoint_message
142
+ tail = production? ? "Telemetria DISABILITATA in production." : "Consentito solo in sviluppo."
143
+ "CloseYourIt: endpoint_url usa http:// non sicuro (#{endpoint_url}) — il token viaggerebbe in chiaro. #{tail}"
144
+ end
145
+
146
+ def parsed_endpoint
147
+ return nil if blank?(endpoint_url)
148
+
149
+ URI.parse(endpoint_url)
150
+ rescue URI::InvalidURIError
151
+ nil
152
+ end
153
+
154
+ def detect_environment
155
+ return ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env
156
+
157
+ ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
158
+ end
159
+
160
+ def default_threads
161
+ [ (Concurrent.processor_count / 2.0).ceil, 1 ].max
162
+ end
163
+
164
+ def blank?(value)
165
+ value.nil? || value.to_s.strip.empty?
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "socket"
5
+
6
+ module CloseYourIt
7
+ # Base degli eventi di telemetria. Le sottoclassi implementano `#to_h` e `#ingest_path`
8
+ # (il path API a cui l'evento va spedito: errori → /events, metriche → /metrics).
9
+ class Event
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ @occurred_at = Time.now.utc.iso8601
13
+ end
14
+
15
+ def to_h
16
+ raise NotImplementedError, "#{self.class} deve implementare #to_h"
17
+ end
18
+
19
+ def ingest_path(_project_id)
20
+ raise NotImplementedError, "#{self.class} deve implementare #ingest_path"
21
+ end
22
+
23
+ private
24
+
25
+ def environment
26
+ @configuration.environment
27
+ end
28
+
29
+ def sdk
30
+ { "name" => "closeyourit-ruby", "version" => VERSION }
31
+ end
32
+
33
+ def server_name
34
+ return nil unless @configuration.send_server_name
35
+
36
+ Socket.gethostname
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def compact(hash)
42
+ hash.reject { |_key, value| value.nil? }
43
+ end
44
+
45
+ # Fusione ricorsiva: gli Hash annidati vengono fusi (es. `contexts.runtime` preservato
46
+ # mentre lo scope aggiunge `contexts.active_job`), gli scalari sovrascritti.
47
+ def deep_merge(base, override)
48
+ base.merge(override) do |_key, old_value, new_value|
49
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
50
+ deep_merge(old_value, new_value)
51
+ else
52
+ new_value
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "socket"
5
+ require_relative "../event"
6
+ require_relative "../scrubber"
7
+
8
+ module CloseYourIt
9
+ # Trasforma un'eccezione Ruby nel **payload evento Sentry** che il backend CloseYourIt ingerisce
10
+ # (Errors::Ingest::Normalize). Usa `backtrace_locations` (niente regex) e mette la cause-chain in
11
+ # `exception.values` ordinata dall'esterna alla principale (Sentry: values.last = il crash).
12
+ class ErrorEvent < Event
13
+ def self.from_exception(exception, configuration:, handled: false, level: "error", contexts: nil)
14
+ new(exception, configuration, handled: handled, level: level, contexts: contexts)
15
+ end
16
+
17
+ def initialize(exception, configuration, handled: false, level: "error", contexts: nil)
18
+ super(configuration)
19
+ @exception = exception
20
+ @handled = handled
21
+ @level = level
22
+ @contexts = contexts
23
+ @scrubber = Scrubber.new(configuration)
24
+ end
25
+
26
+ def to_h
27
+ base = compact(
28
+ "event_id" => SecureRandom.uuid.delete("-"),
29
+ "timestamp" => @occurred_at,
30
+ "platform" => "ruby",
31
+ "level" => @level,
32
+ "environment" => environment,
33
+ "release" => @configuration.release,
34
+ "server_name" => server_name,
35
+ "exception" => { "values" => exception_values },
36
+ "contexts" => { "runtime" => { "name" => "ruby", "version" => RUBY_VERSION } },
37
+ "sdk" => sdk
38
+ )
39
+ # Fonde il contesto per-richiesta/job (user/tags/extra/contexts/request) raccolto nello Scope.
40
+ merged = deep_merge(base, CloseYourIt::Scope.current.to_event_hash)
41
+ # Context extra passato esplicitamente (es. rails_error dall'ErrorReporter).
42
+ @contexts ? deep_merge(merged, { "contexts" => @contexts }) : merged
43
+ end
44
+
45
+ def ingest_path(project_id)
46
+ "/api/v1/projects/#{project_id}/events"
47
+ end
48
+
49
+ private
50
+
51
+ # Cause-chain → array Sentry: causa più esterna prima, eccezione principale ULTIMA.
52
+ def exception_values
53
+ chain = []
54
+ seen = []
55
+ current = @exception
56
+ while current && !seen.include?(current)
57
+ chain << single_exception(current)
58
+ seen << current
59
+ current = current.cause
60
+ end
61
+ chain.reverse
62
+ end
63
+
64
+ def single_exception(exception)
65
+ {
66
+ "type" => exception.class.name,
67
+ "value" => @scrubber.scrub_message(exception.message),
68
+ "stacktrace" => { "frames" => frames(exception.backtrace_locations) },
69
+ "mechanism" => { "type" => "ruby", "handled" => @handled }
70
+ }
71
+ end
72
+
73
+ # Sentry-style: frame più recente per ultimo; chiavi filename/function/lineno/in_app/abs_path.
74
+ def frames(locations)
75
+ return [] if locations.nil?
76
+
77
+ locations.reverse.map do |loc|
78
+ {
79
+ "filename" => loc.path,
80
+ "abs_path" => loc.path,
81
+ "function" => loc.label,
82
+ "lineno" => loc.lineno,
83
+ "in_app" => in_app?(loc.path)
84
+ }
85
+ end
86
+ end
87
+
88
+ def in_app?(path)
89
+ return false if path.nil?
90
+
91
+ !path.include?("/gems/") && !path.include?(RbConfig::CONFIG["rubylibdir"].to_s)
92
+ end
93
+ end
94
+ end