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.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ module Sidekiq
5
+ # Error handler Sidekiq (registrato dal railtie solo se Sidekiq è presente). Sidekiq invoca
6
+ # `call(exception, context, config)` e NON ri-solleva → qui catturiamo e basta.
7
+ class ErrorHandler
8
+ def call(exception, context, _config = nil)
9
+ apply_job_scope(context)
10
+ CloseYourIt.capture_exception(exception, handled: false)
11
+ ensure
12
+ CloseYourIt::Scope.reset!
13
+ end
14
+
15
+ private
16
+
17
+ def apply_job_scope(context)
18
+ job = (context && context[:job]) || {}
19
+ CloseYourIt.set_tag("job.class", job["class"]) if job["class"]
20
+ CloseYourIt.set_tag("job.queue", job["queue"]) if job["queue"]
21
+ CloseYourIt.set_context("sidekiq", { "jid" => job["jid"] }) if job["jid"]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../events/slow_query_event"
4
+ require_relative "../scrubber"
5
+
6
+ module CloseYourIt
7
+ module Subscribers
8
+ # Riceve i dati di un evento `sql.active_record` e, se la query supera la soglia
9
+ # (escludendo SCHEMA/CACHE/TRANSACTION), invia un evento `slow_query`.
10
+ # Logica pura: il wiring ad ActiveSupport::Notifications vive nel Railtie.
11
+ class SlowQuery
12
+ IGNORED_NAMES = %w[SCHEMA CACHE TRANSACTION].freeze
13
+
14
+ def initialize(configuration = nil)
15
+ @configuration = configuration
16
+ end
17
+
18
+ def record(name:, duration_ms:, sql:, cached: false, connection: nil,
19
+ binds: nil, type_casted_binds: nil, source: nil)
20
+ config = @configuration || CloseYourIt.configuration
21
+ return if ignored_name?(name)
22
+ return if duration_ms < config.slow_query_threshold_ms
23
+
24
+ event = SlowQueryEvent.new(
25
+ { name: name, sql: sql, cached: cached, connection: connection,
26
+ binds: binds, type_casted_binds: type_casted_binds, source: source },
27
+ duration_ms,
28
+ config
29
+ )
30
+ CloseYourIt.capture_event(event)
31
+ end
32
+
33
+ # Breadcrumb per OGNI query non di sistema (non solo lente): SQL offuscato, niente bind.
34
+ # Dà la cronologia "quali query prima del crash" allegata all'evento d'errore.
35
+ def breadcrumb(name:, sql:, duration_ms:, cached: false)
36
+ config = @configuration || CloseYourIt.configuration
37
+ return if ignored_name?(name)
38
+ return unless config.breadcrumbs_enabled
39
+
40
+ CloseYourIt.add_breadcrumb(
41
+ category: "query",
42
+ type: "query",
43
+ message: Scrubber.new(config).obfuscate_sql(sql),
44
+ data: { "name" => name, "duration_ms" => duration_ms.to_f.round(2), "cached" => cached }
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def ignored_name?(name)
51
+ name.nil? || IGNORED_NAMES.include?(name)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module CloseYourIt
8
+ # Spedisce un payload a un path di ingest (errori → /events, metriche → /metrics) via HTTP POST
9
+ # con `Authorization: Bearer`. Mai solleva: ogni errore di rete è loggato e ingoiato.
10
+ class Transport
11
+ OPEN_TIMEOUT = 2
12
+ READ_TIMEOUT = 3
13
+
14
+ def initialize(configuration)
15
+ @configuration = configuration
16
+ end
17
+
18
+ def send_event(payload, path:)
19
+ post(payload, path)
20
+ rescue StandardError => e
21
+ CloseYourIt.logger.error("CloseYourIt transport: #{e.class}: #{e.message}")
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ def post(payload, path)
28
+ uri = URI.parse("#{base_url}#{path}")
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == "https"
31
+ http.open_timeout = OPEN_TIMEOUT
32
+ http.read_timeout = READ_TIMEOUT
33
+
34
+ request = Net::HTTP::Post.new(uri.request_uri)
35
+ request["Authorization"] = "Bearer #{@configuration.token}"
36
+ request["Content-Type"] = "application/json"
37
+ request["User-Agent"] = "closeyourit-ruby/#{VERSION}"
38
+ request.body = JSON.generate(payload)
39
+
40
+ http.request(request)
41
+ end
42
+
43
+ def base_url
44
+ @configuration.endpoint_url.to_s.chomp("/")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloseYourIt
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "closeyourit/version"
6
+ require_relative "closeyourit/configuration"
7
+ require_relative "closeyourit/breadcrumb"
8
+ require_relative "closeyourit/scope"
9
+ require_relative "closeyourit/scrubber"
10
+ require_relative "closeyourit/background_worker"
11
+ require_relative "closeyourit/transport"
12
+ require_relative "closeyourit/event"
13
+ require_relative "closeyourit/events/error_event"
14
+ require_relative "closeyourit/events/message_event"
15
+ require_relative "closeyourit/events/slow_query_event"
16
+ require_relative "closeyourit/events/slow_method_event"
17
+ require_relative "closeyourit/subscribers/slow_query"
18
+ require_relative "closeyourit/instrumenter"
19
+ require_relative "closeyourit/monitor"
20
+ require_relative "closeyourit/client"
21
+ require_relative "closeyourit/rails/capture_exceptions"
22
+ require_relative "closeyourit/rails/request_context"
23
+ require_relative "closeyourit/rails/active_job_extension"
24
+ require_relative "closeyourit/rails/error_subscriber"
25
+ require_relative "closeyourit/sidekiq/error_handler"
26
+
27
+ # CloseYourIt — client di telemetria (errori + statistiche di query/metodi lenti)
28
+ # che invia gli eventi all'endpoint di ingest di CloseYourIt.
29
+ #
30
+ # Entry point della gemma (file con trattino come `sentry-ruby`):
31
+ # `require "closeyourit-ruby"` carica il modulo `CloseYourIt`.
32
+ module CloseYourIt
33
+ # Eccezione base interna: usata per evitare loop (le nostre eccezioni non vengono catturate).
34
+ class Error < StandardError; end
35
+
36
+ CAPTURED_FLAG = :@__closeyourit_captured
37
+
38
+ class << self
39
+ # Configura il client. Senza token/endpoint → no-op.
40
+ def init
41
+ @configuration = Configuration.new
42
+ @client = nil
43
+ yield(@configuration) if block_given?
44
+ @configuration.validate!
45
+ @configuration
46
+ end
47
+
48
+ def configuration
49
+ @configuration ||= Configuration.new
50
+ end
51
+
52
+ def configured?
53
+ !@configuration.nil?
54
+ end
55
+
56
+ def enabled?
57
+ configuration.enabled?
58
+ end
59
+
60
+ # Cattura un'eccezione e la spedisce (fire-and-forget). No-op se disabilitato,
61
+ # se l'eccezione è esclusa o già catturata.
62
+ def capture_exception(exception, handled: false, level: "error", contexts: nil)
63
+ return nil unless enabled?
64
+ return nil if ignored_exception?(exception)
65
+ return nil if exception_captured?(exception)
66
+
67
+ mark_captured(exception)
68
+ return nil unless sampled?
69
+
70
+ event = ErrorEvent.from_exception(
71
+ exception, configuration: configuration, handled: handled, level: level, contexts: contexts
72
+ )
73
+ client.capture_event(event)
74
+ end
75
+
76
+ # Spedisce un evento già costruito (slow_query/slow_method).
77
+ def capture_event(event)
78
+ return nil unless enabled?
79
+
80
+ client.capture_event(event)
81
+ end
82
+
83
+ # Invia un messaggio diagnostico esplicito (non un'eccezione). Soggetto a sampling + scope.
84
+ # CloseYourIt.capture_message("cache miss storm", level: "warning")
85
+ def capture_message(message, level: "info")
86
+ return nil unless enabled?
87
+ return nil unless sampled?
88
+
89
+ event = MessageEvent.new(message, level: level, configuration: configuration)
90
+ client.capture_event(event)
91
+ end
92
+
93
+ # Cronometra un blocco e invia un slow_method se supera la soglia.
94
+ # CloseYourIt.measure("checkout.total") { ... }
95
+ def measure(label, &block)
96
+ Instrumenter.measure(label, &block)
97
+ end
98
+
99
+ # --- Scope per-richiesta/job (user/tags/extra/contexts) ---
100
+ # Arricchiscono l'evento corrente; resettati a fine richiesta/job da middleware e estensioni.
101
+
102
+ def set_user(attributes)
103
+ Scope.current.set_user(attributes)
104
+ end
105
+
106
+ def set_tag(key, value)
107
+ Scope.current.set_tag(key, value)
108
+ end
109
+
110
+ def set_tags(attributes)
111
+ Scope.current.set_tags(attributes)
112
+ end
113
+
114
+ def set_context(key, attributes)
115
+ Scope.current.set_context(key, attributes)
116
+ end
117
+
118
+ def set_extra(key, value)
119
+ Scope.current.set_extra(key, value)
120
+ end
121
+
122
+ def configure_scope
123
+ yield(Scope.current) if block_given?
124
+ end
125
+
126
+ def clear_scope
127
+ Scope.reset!
128
+ end
129
+
130
+ # Aggiunge una briciola di contesto (query, navigazione, evento custom) all'evento corrente.
131
+ # No-op se breadcrumbs disabilitati; `data` viene scrubato (denylist) prima di essere salvato.
132
+ def add_breadcrumb(message: nil, category: nil, type: "default", level: "info", data: {})
133
+ return nil unless configuration.breadcrumbs_enabled
134
+
135
+ scrubbed = data.nil? || data.empty? ? data : Scrubber.new(configuration).filter_params(data)
136
+ Scope.current.add_breadcrumb(
137
+ Breadcrumb.new(message: message, category: category, type: type, level: level, data: scrubbed)
138
+ )
139
+ end
140
+
141
+ def logger
142
+ @logger ||= default_logger
143
+ end
144
+
145
+ attr_writer :logger
146
+
147
+ private
148
+
149
+ def client
150
+ @client ||= Client.new(configuration)
151
+ end
152
+
153
+ def ignored_exception?(exception)
154
+ return true if exception.is_a?(CloseYourIt::Error)
155
+
156
+ names = exception.class.ancestors.grep(Class).map(&:name).compact
157
+ configuration.excluded_exceptions.any? do |matcher|
158
+ if matcher.is_a?(Regexp)
159
+ names.any? { |name| matcher.match?(name) } || matcher.match?(exception.message.to_s)
160
+ else
161
+ names.include?(matcher)
162
+ end
163
+ end
164
+ end
165
+
166
+ # Sampling probabilistico: 1.0 invia sempre, 0.0 mai, intermedio via Random.rand.
167
+ def sampled?
168
+ rate = configuration.sample_rate.to_f
169
+ return true if rate >= 1.0
170
+ return false if rate <= 0.0
171
+
172
+ Random.rand < rate
173
+ end
174
+
175
+ def exception_captured?(exception)
176
+ exception.instance_variable_defined?(CAPTURED_FLAG)
177
+ end
178
+
179
+ def mark_captured(exception)
180
+ exception.instance_variable_set(CAPTURED_FLAG, true)
181
+ end
182
+
183
+ def default_logger
184
+ ::Logger.new($stdout).tap do |l|
185
+ l.level = ::Logger::WARN
186
+ l.progname = "CloseYourIt"
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ # Integrazione Rails automatica (solo se Rails è presente).
193
+ require_relative "closeyourit/rails/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: closeyourit-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alessio Bussolari
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ description: Gemma client che cattura eccezioni e le statistiche di query e metodi
27
+ lenti e le invia, fire-and-forget, all'endpoint di ingest di CloseYourIt.
28
+ email:
29
+ - hello@bussolarialessio.me
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - lib/closeyourit-ruby.rb
37
+ - lib/closeyourit/background_worker.rb
38
+ - lib/closeyourit/breadcrumb.rb
39
+ - lib/closeyourit/breadcrumb_buffer.rb
40
+ - lib/closeyourit/client.rb
41
+ - lib/closeyourit/configuration.rb
42
+ - lib/closeyourit/event.rb
43
+ - lib/closeyourit/events/error_event.rb
44
+ - lib/closeyourit/events/message_event.rb
45
+ - lib/closeyourit/events/slow_method_event.rb
46
+ - lib/closeyourit/events/slow_query_event.rb
47
+ - lib/closeyourit/instrumenter.rb
48
+ - lib/closeyourit/monitor.rb
49
+ - lib/closeyourit/rails/active_job_extension.rb
50
+ - lib/closeyourit/rails/capture_exceptions.rb
51
+ - lib/closeyourit/rails/error_subscriber.rb
52
+ - lib/closeyourit/rails/query_source.rb
53
+ - lib/closeyourit/rails/railtie.rb
54
+ - lib/closeyourit/rails/request_context.rb
55
+ - lib/closeyourit/scope.rb
56
+ - lib/closeyourit/scrubber.rb
57
+ - lib/closeyourit/sidekiq/error_handler.rb
58
+ - lib/closeyourit/subscribers/slow_query.rb
59
+ - lib/closeyourit/transport.rb
60
+ - lib/closeyourit/version.rb
61
+ homepage: https://github.com/bussolabs/closeyourit-ruby
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/bussolabs/closeyourit-ruby
66
+ source_code_uri: https://github.com/bussolabs/closeyourit-ruby
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '4.0'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.10
82
+ specification_version: 4
83
+ summary: Client di telemetria per CloseYourIt (errori + query/metodi lenti).
84
+ test_files: []