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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +177 -0
- data/lib/closeyourit/background_worker.rb +45 -0
- data/lib/closeyourit/breadcrumb.rb +35 -0
- data/lib/closeyourit/breadcrumb_buffer.rb +32 -0
- data/lib/closeyourit/client.rb +30 -0
- data/lib/closeyourit/configuration.rb +168 -0
- data/lib/closeyourit/event.rb +57 -0
- data/lib/closeyourit/events/error_event.rb +94 -0
- data/lib/closeyourit/events/message_event.rb +35 -0
- data/lib/closeyourit/events/slow_method_event.rb +61 -0
- data/lib/closeyourit/events/slow_query_event.rb +61 -0
- data/lib/closeyourit/instrumenter.rb +29 -0
- data/lib/closeyourit/monitor.rb +34 -0
- data/lib/closeyourit/rails/active_job_extension.rb +42 -0
- data/lib/closeyourit/rails/capture_exceptions.rb +21 -0
- data/lib/closeyourit/rails/error_subscriber.rb +30 -0
- data/lib/closeyourit/rails/query_source.rb +17 -0
- data/lib/closeyourit/rails/railtie.rb +74 -0
- data/lib/closeyourit/rails/request_context.rb +75 -0
- data/lib/closeyourit/scope.rb +115 -0
- data/lib/closeyourit/scrubber.rb +71 -0
- data/lib/closeyourit/sidekiq/error_handler.rb +25 -0
- data/lib/closeyourit/subscribers/slow_query.rb +55 -0
- data/lib/closeyourit/transport.rb +47 -0
- data/lib/closeyourit/version.rb +5 -0
- data/lib/closeyourit-ruby.rb +193 -0
- metadata +84 -0
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
|