blipit 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3b3f5df2c1e54476b7900840749686a2fb0d3428f41722ccfe507431d52bbf7
4
- data.tar.gz: 509a9d7e0673ea9a82600a798e6f4f71e8d5341633b46754b1e6292a2e4d94b2
3
+ metadata.gz: '08dd6a2a074cf2d5363dc7a1640afb6e95605dcc8cf142319d788aabaf46c877'
4
+ data.tar.gz: f07cb8341c0c1c9a2d6f2c3946dbcb25cbead6fded1f24b0a9e0dd7bc494ca38
5
5
  SHA512:
6
- metadata.gz: 8039c976e246061ceb6144765687e628708c7b317d414fc0e5cc6aa33231f4f857fe8d0b284457ace8abdaf11c8ab9eb974216ceabcbadfc1da4b7ac4513719b
7
- data.tar.gz: 554c68f653f4c30640355ffc67353e386d81a0271a62aa699780d9af6446543305a381d188c6d8f2c0ae72f3344cc5aee9557dfe79cd33c8872b919e1b7b1bdb
6
+ metadata.gz: 6f2a8803675ecfbf37e1eabe6949c182bd37e68ad9b9cd6650f3dae0af080c99cbeaf97d2e1a6ca8997f7d49f712e041c687fa52a7837b6bd06c3c2d8a7df10b
7
+ data.tar.gz: 39bcdf4469045c253649823b9db645e7161ef0c77ad82f97f8601fda97f14c9209af4e86bd4fe8bbeebd5032ee9a29da716880f3a50496cccee8f32113380d47
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.3.0
2
+
3
+ - Runs on Blipit's own core, blipit-nuggets, with no third-party monitoring gem and no runtime dependencies. Events go to `POST /api/v1/events` in Blipit's event format with the `X-Blipit-Key` header; spans go to `/v1/traces`.
4
+ - The same calls keep working: `Blipit.init` (keywords and the configuration block), `capture_exception`, `capture_message`, `capture_security`, `set_user`, `set_tag`, `add_breadcrumb`, `flush`. New: `set_tags`, `set_extras`, `set_context`.
5
+ - Rails needs no extra gem: a Railtie adds `Blipit::Rack`, so controller errors, rendered 500s, Active Job failures and `Rails.error` reports are sent, with the route as the span name. Plain Rack and Sinatra apps add `use Blipit::Rack`. Sidekiq jobs are wrapped on their own.
6
+ - `Net::HTTP` calls and Active Record queries become breadcrumbs (SQL literals replaced by `?`); uncaught exceptions are sent from `at_exit`.
7
+ - Stack frames carry five lines of source around each line; `cause` chains are listed under `extra.exception_chain`.
8
+ - Sending runs on one background thread with a queue of at most 100 events, is flushed at exit, pauses on `429` for the `Retry-After` given, and retries a `5xx` twice. Forked workers (Puma, Unicorn) get their own sender.
9
+ - Passwords, tokens, cookies, sessions, API keys and card numbers are replaced with `[Filtered]` before sending.
10
+ - `project:` is no longer needed; it is still accepted. `Blipit.dsn` is gone.
11
+ - `before_send` now receives the event as a Hash in Blipit's format.
12
+
1
13
  ## 0.2.1
2
14
 
3
15
  - Events carry the `gem:blipit` package and version, so the dashboard can show when this project runs an old Blipit SDK. A `before_send` you set still runs after it.
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Blipit error monitoring for Ruby. Know your app broke before your users tell you.
4
4
 
5
+ Built on Blipit's own core, blipit-nuggets. No third-party monitoring gem and no runtime dependencies.
6
+
5
7
  ## Install
6
8
 
7
9
  ```sh
@@ -17,27 +19,40 @@ Call `Blipit.init` once at startup, before the app starts serving requests.
17
19
  ```ruby
18
20
  require "blipit"
19
21
 
20
- Blipit.init(key: "<public key>", project: <project id>, environment: "production", release: "myapp@1.4.2")
22
+ Blipit.init(key: "<public key>", environment: "production", release: "myapp@1.4.2")
21
23
  ```
22
24
 
23
- Both values are on the project's Keys page at app.blipit.io. Unhandled exceptions are reported on their own. If the key or project id is empty (for example an unset environment variable), `init` prints a warning, returns `false` and Blipit stays off; it never raises, so a missing key cannot stop the app from booting.
25
+ The key is on the project's Keys page at app.blipit.io. `project:` is still accepted for older setups and no longer needed. If the key is empty (for example an unset environment variable), `init` prints a warning, returns `false` and Blipit stays off; it never raises, so a missing key cannot stop the app from booting.
26
+
27
+ A block works too, with the same option names:
28
+
29
+ ```ruby
30
+ Blipit.init(key: ENV["BLIPIT_KEY"]) do |config|
31
+ config.environment = "staging"
32
+ config.traces_sample_rate = 0.2
33
+ config.before_send = ->(event, hint) { event }
34
+ end
35
+ ```
24
36
 
25
37
  ## Rails
26
38
 
27
- Rails rescues controller and job errors before they reach Ruby, so Rails apps need `sentry-rails` next to `blipit`:
39
+ Nothing else to install. Put the `Blipit.init` call in `config/initializers/blipit.rb`:
28
40
 
29
41
  ```ruby
30
- gem "blipit"
31
- gem "sentry-rails"
42
+ Blipit.init(key: ENV["BLIPIT_KEY"], environment: Rails.env, release: ENV["GIT_SHA"])
32
43
  ```
33
44
 
34
- Then put the `Blipit.init` call in `config/initializers/blipit.rb`:
45
+ `blipit` adds `Blipit::Rack` to the middleware stack, so controller errors and errors Rails renders as a 500 page are reported with the request (URL, method, user agent; never cookies, auth headers or bodies). Active Job failures, errors passed to `Rails.error` and Active Record queries (as breadcrumbs, literals replaced by `?`) come along. 404s are left out.
46
+
47
+ ## Rack and Sinatra
35
48
 
36
49
  ```ruby
37
- Blipit.init(key: ENV["BLIPIT_KEY"], project: ENV["BLIPIT_PROJECT"], environment: Rails.env, release: ENV["GIT_SHA"])
50
+ use Blipit::Rack
38
51
  ```
39
52
 
40
- `blipit` loads `sentry-rails` on its own when Rails is running, and `Blipit.init` configures it, so controller errors, job errors and request context are reported. Without `sentry-rails` in the Gemfile, `Blipit.init` prints a warning saying controller errors will not be sent.
53
+ ## Sidekiq
54
+
55
+ Jobs are wrapped on their own when Sidekiq is loaded before `Blipit.init`: a failing job is reported with its class and queue.
41
56
 
42
57
  ## Manual capture
43
58
 
@@ -46,6 +61,7 @@ Blipit.capture_exception(error)
46
61
  Blipit.capture_message("cache miss rate above 50%", level: :warning)
47
62
  Blipit.set_user(id: "42", email: "ana@example.com")
48
63
  Blipit.set_tag("region", "ap-southeast-1")
64
+ Blipit.set_context("order", id: 1042, total: 99.5)
49
65
  Blipit.add_breadcrumb("cache cleared", category: "cache")
50
66
  ```
51
67
 
@@ -60,12 +76,42 @@ Both forms work. Without a kind or an actor it prints a warning and sends nothin
60
76
 
61
77
  `kind` is one of `login_failed`, `login_blocked`, `login_succeeded`, `password_reset_requested` or `privilege_granted`; any other kind arrives as a plain message. Login attempts need the project's secret key (init with it on the server) and the Scale plan; with the public key ingest refuses them with 403 `security_needs_secret_key`.
62
78
 
63
- ## Performance
79
+ ## Options
64
80
 
65
- Pass `traces_sample_rate: 0.2` to `init` and requests show up on the Performance page.
81
+ | Option | Default | |
82
+ | --- | --- | --- |
83
+ | `environment`, `release` | `production`, none | Shown on every issue. |
84
+ | `sample_rate` | `1.0` | Share of error events to send. |
85
+ | `traces_sample_rate` | `0.0` | Share of requests and jobs traced for Pulse. |
86
+ | `before_send` | none | `->(event, hint)`: change the event (a Hash in Blipit's format) or return `nil` to drop it. |
87
+ | `before_breadcrumb` | none | Same for breadcrumbs. |
88
+ | `excluded_exceptions` | `[]` | Classes or class names never sent. |
89
+ | `enabled_environments` | all | Blipit stays off in any other environment. |
90
+ | `server_name`, `max_breadcrumbs`, `shutdown_timeout`, `debug` | | |
91
+ | `default_integrations` | `true` | `false` keeps only what you capture yourself. |
66
92
 
67
- Docs: https://docs.blipit.io/ruby
93
+ Other options from older versions are accepted and ignored.
94
+
95
+ ## Pulse (performance)
96
+
97
+ Pass `traces_sample_rate: 0.2` to `init`. Requests (named after the route), jobs, `Net::HTTP` calls and SQL queries show up on the Pulse page, and errors link to their trace.
98
+
99
+ ## How sending works
100
+
101
+ Events go to `POST /api/v1/events` and spans to `/v1/traces` from one background thread, with a queue of at most 100 events (the oldest is dropped first), flushed at exit. A `429` pauses sending for the `Retry-After` the server gives; a `5xx` or network error is retried twice. Keys and values that look like passwords, tokens, cookies, sessions or card numbers are replaced with `[Filtered]` before anything leaves the process.
68
102
 
69
103
  ## Updates
70
104
 
71
105
  In development Blipit prints one line when a newer version is out, with the reason and the command. Update with `bundle update blipit`. Details: https://docs.blipit.io/updates
106
+
107
+ ## Tests
108
+
109
+ ```sh
110
+ gem install minitest webrick
111
+ ruby -Ilib test/blipit_test.rb
112
+ ruby -Ilib test/rails_test.rb
113
+ ```
114
+
115
+ The Rails smoke test needs `railties` and `activejob` and exits quietly without them.
116
+
117
+ Docs: https://docs.blipit.io/ruby
@@ -0,0 +1,267 @@
1
+ module Blipit
2
+ module Integrations
3
+ module_function
4
+
5
+ def capture_error(error, handled: false)
6
+ client = Blipit.client
7
+ return unless client
8
+
9
+ client.capture(client.exception_event(error, handled: handled), { exception: error })
10
+ end
11
+
12
+ def child_span(name, attrs)
13
+ client = Blipit.client
14
+ return nil if client.nil? || Thread.current[:blipit_internal]
15
+
16
+ client.start_span(name, 3, attrs)
17
+ end
18
+
19
+ def finish(span, error = false, start_ns: nil)
20
+ client = Blipit.client
21
+ client.finish_span(span, error, start_ns: start_ns) if client
22
+ end
23
+
24
+ def breadcrumb(category, message, data, level = "info")
25
+ client = Blipit.client
26
+ return if client.nil? || Thread.current[:blipit_internal]
27
+
28
+ client.add_breadcrumb({ category: category, message: message, data: data, level: level })
29
+ end
30
+
31
+ def name_route(route)
32
+ span = Thread.current[:blipit_span]
33
+ return unless span && span.sampled && span.kind == 2 && route && !route.to_s.empty?
34
+
35
+ r = route.to_s.sub(/\(\.:format\)\z/, "")[0, 512]
36
+ span.name = "#{span.attrs['http.request.method'] || 'GET'} #{r}"
37
+ span.attrs["http.route"] = r
38
+ end
39
+
40
+ def around(kind, name, attrs, request = nil)
41
+ client = Blipit.client
42
+ return yield(nil) unless client
43
+
44
+ outer = Thread.current[:blipit_span]
45
+ return yield(nil) if outer && outer.kind == kind
46
+
47
+ previous_scope = client.isolate
48
+ client.scope.request = request if request
49
+ previous_span = outer
50
+ span = client.start_span(name, kind, attrs, root: true)
51
+ Thread.current[:blipit_span] = span if span
52
+ begin
53
+ yield span
54
+ ensure
55
+ Thread.current[:blipit_span] = previous_span
56
+ client.restore(previous_scope)
57
+ end
58
+ end
59
+
60
+ def db(name, sql, duration_ms, system)
61
+ breadcrumb("query", Nuggets.redact_sql(sql), { "db.system" => system })
62
+ client = Blipit.client
63
+ span = child_span("#{Nuggets.sql_op(sql)} #{system}", "db.system" => system, "db.operation.name" => Nuggets.sql_op(sql), "db.query.text" => Nuggets.redact_sql(sql))
64
+ return unless span && client
65
+
66
+ finish(span, false, start_ns: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) - (duration_ms.to_f * 1_000_000).to_i)
67
+ end
68
+
69
+ def job(name, system)
70
+ around(5, name, { "messaging.system" => system, "messaging.operation.name" => "process" }) do |span|
71
+ client = Blipit.client
72
+ client.scope.tags["job"] = name.to_s[0, 256] if client
73
+ begin
74
+ result = yield
75
+ finish(span)
76
+ result
77
+ rescue Exception => e
78
+ capture_error(e) unless e.is_a?(SystemExit) || e.is_a?(SignalException)
79
+ finish(span, true)
80
+ raise
81
+ end
82
+ end
83
+ end
84
+
85
+ module NetHTTP
86
+ def request(req, body = nil, &block)
87
+ client = Blipit.client
88
+ return super if client.nil? || !started? || Thread.current[:blipit_internal]
89
+
90
+ host = port == (use_ssl? ? 443 : 80) ? address.to_s : "#{address}:#{port}"
91
+ return super if host.downcase == client.own_host
92
+
93
+ url = Nuggets.clean_url("#{use_ssl? ? 'https' : 'http'}://#{host}#{req.path}")
94
+ span = Integrations.child_span("#{req.method} #{host}", "http.request.method" => req.method, "url.full" => url, "server.address" => address.to_s)
95
+ begin
96
+ response = super
97
+ rescue StandardError
98
+ Integrations.finish(span, true)
99
+ Integrations.breadcrumb("http", "#{req.method} #{url} failed", { "method" => req.method, "url" => url }, "error")
100
+ raise
101
+ end
102
+ status = response.code.to_i
103
+ span.attrs["http.response.status_code"] = status if span && span.sampled
104
+ Integrations.finish(span, status >= 500)
105
+ Integrations.breadcrumb("http", "#{req.method} #{url} [#{status}]", { "method" => req.method, "url" => url, "status_code" => status }, status >= 500 ? "error" : "info")
106
+ response
107
+ end
108
+ end
109
+
110
+ def install_net_http
111
+ return if @net_http
112
+
113
+ @net_http = true
114
+ Net::HTTP.prepend(NetHTTP)
115
+ end
116
+
117
+ def install_at_exit
118
+ return if @at_exit
119
+
120
+ @at_exit = true
121
+ at_exit do
122
+ error = $!
123
+ if error && !error.is_a?(SystemExit) && !error.is_a?(Interrupt)
124
+ client = Blipit.client
125
+ client.capture(client.exception_event(error, handled: false, level: "fatal"), { exception: error }) if client
126
+ end
127
+ Blipit.flush((Blipit.client && Blipit.client.options[:shutdown_timeout]) || 2)
128
+ end
129
+ end
130
+
131
+ def install_active_support
132
+ return if @active_support || !defined?(::ActiveSupport::Notifications)
133
+
134
+ @active_support = true
135
+ ::ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
136
+ payload = args.last
137
+ next if payload[:name] == "SCHEMA" || payload[:cached]
138
+
139
+ connection = payload[:connection]
140
+ system = connection.respond_to?(:adapter_name) ? connection.adapter_name.to_s.downcase : "sql"
141
+ duration = args[2] && args[1] ? (args[2] - args[1]) * 1000.0 : 0
142
+ Integrations.db("sql", payload[:sql].to_s, duration, system)
143
+ end
144
+ if defined?(::ActiveSupport.on_load)
145
+ ::ActiveSupport.on_load(:active_job) do
146
+ around_perform { |job, block| Blipit::Integrations.job(job.class.name, "active_job") { block.call } }
147
+ end
148
+ end
149
+ end
150
+
151
+ def install_rails_errors
152
+ return if @rails_errors || !defined?(::Rails) || !::Rails.respond_to?(:error) || !::Rails.error.respond_to?(:subscribe)
153
+
154
+ @rails_errors = true
155
+ ::Rails.error.subscribe(ErrorSubscriber.new)
156
+ end
157
+
158
+ class ErrorSubscriber
159
+ def report(error, handled:, severity: :error, context: {}, source: nil)
160
+ client = Blipit.client
161
+ return if client.nil? || source.to_s == "application.action_dispatch"
162
+
163
+ level = { error: "error", warning: "warning", info: "info" }[severity] || "error"
164
+ event = client.exception_event(error, handled: handled, level: level)
165
+ return unless event
166
+
167
+ extra = context.is_a?(Hash) ? context.reject { |k, _| k.to_s == "controller" || k.to_s == "request" } : {}
168
+ event["extra"] = (event["extra"] || {}).merge("rails" => extra.transform_keys(&:to_s)) unless extra.empty?
169
+ event["tags"] = { "rails.source" => source.to_s } if source
170
+ client.capture(event, { exception: error })
171
+ rescue StandardError
172
+ nil
173
+ end
174
+ end
175
+
176
+ class SidekiqMiddleware
177
+ def call(_worker, job, queue)
178
+ name = (job["wrapped"] || job["class"]).to_s
179
+ Integrations.job(name, "sidekiq") do
180
+ client = Blipit.client
181
+ client.scope.tags["sidekiq.queue"] = queue.to_s[0, 256] if client
182
+ yield
183
+ end
184
+ end
185
+ end
186
+
187
+ def install_sidekiq
188
+ return if @sidekiq || !defined?(::Sidekiq) || !::Sidekiq.respond_to?(:configure_server)
189
+
190
+ @sidekiq = true
191
+ ::Sidekiq.configure_server do |config|
192
+ config.server_middleware { |chain| chain.add(SidekiqMiddleware) }
193
+ end
194
+ end
195
+
196
+ def install(options)
197
+ return if options[:default_integrations] == false
198
+
199
+ install_at_exit
200
+ install_net_http
201
+ install_active_support
202
+ install_rails_errors
203
+ install_sidekiq
204
+ end
205
+ end
206
+
207
+ class Rack
208
+ def initialize(app)
209
+ @app = app
210
+ end
211
+
212
+ def call(env)
213
+ return @app.call(env) unless Blipit.client
214
+
215
+ scheme = env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http"
216
+ host = env["HTTP_HOST"] || env["SERVER_NAME"].to_s
217
+ path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
218
+ method = env["REQUEST_METHOD"] || "GET"
219
+ header = ->(name) { env["HTTP_#{name.upcase.tr('-', '_')}"] || (name == "content-type" ? env["CONTENT_TYPE"] : nil) }
220
+ request = Nuggets.request_info(method, "#{scheme}://#{host}#{path}", env["QUERY_STRING"], header)
221
+ clean_path = (path.empty? ? "/" : path)[0, 512]
222
+ Integrations.around(2, "#{method} #{clean_path}", { "http.request.method" => method, "url.path" => clean_path }, request) do |span|
223
+ status = 500
224
+ begin
225
+ status, headers, body = @app.call(env)
226
+ error = env["action_dispatch.exception"] || env["rack.exception"] || env["sinatra.error"]
227
+ Integrations.capture_error(error) if error && status.to_i >= 500
228
+ [status, headers, body]
229
+ rescue Exception => e
230
+ Integrations.capture_error(e) unless e.is_a?(SystemExit) || e.is_a?(SignalException)
231
+ raise
232
+ ensure
233
+ Integrations.name_route(rails_route(env) || (env["sinatra.route"] && env["sinatra.route"].to_s.split(" ", 2).last))
234
+ if span && span.sampled
235
+ span.attrs["http.response.status_code"] = status.to_i
236
+ Integrations.finish(span, status.to_i >= 500)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ class Rack
244
+ private
245
+
246
+ def rails_route(env)
247
+ return env["action_dispatch.route_uri_pattern"] if env["action_dispatch.route_uri_pattern"]
248
+
249
+ route = env["action_dispatch.route"]
250
+ route.respond_to?(:path) && route.path.respond_to?(:spec) ? route.path.spec.to_s : nil
251
+ rescue StandardError
252
+ nil
253
+ end
254
+ end
255
+
256
+ if defined?(::Rails::Railtie)
257
+ class Railtie < ::Rails::Railtie
258
+ initializer "blipit.rack" do |app|
259
+ app.config.middleware.insert(0, Blipit::Rack)
260
+ end
261
+
262
+ config.after_initialize do
263
+ Blipit::Integrations.install_rails_errors if Blipit.client
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,600 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "rbconfig"
4
+ require "securerandom"
5
+ require "socket"
6
+ require "time"
7
+ require "uri"
8
+ require_relative "version"
9
+
10
+ module Blipit
11
+ module Nuggets
12
+ SDK_NAME = "blipit-ruby"
13
+ FILTERED = "[Filtered]"
14
+ LEVELS = { "fatal" => "fatal", "critical" => "fatal", "error" => "error", "warning" => "warning", "warn" => "warning", "info" => "info", "log" => "info", "debug" => "debug" }.freeze
15
+ CATEGORIES = %w[ui.click ui.input navigation http console query custom].freeze
16
+ SECURITY_KINDS = %w[login_failed login_succeeded login_blocked password_reset_requested privilege_granted].freeze
17
+ WARNING_KINDS = %w[login_failed login_blocked privilege_granted].freeze
18
+ SECURITY_FIELDS = { actor_id: 128, ip: 45, user_agent: 512, database: 64, outcome: 64, target: 256 }.freeze
19
+ HEADER_ALLOW = %w[user-agent referer content-type accept-language].freeze
20
+ MAX_EVENTS = 100
21
+ MAX_SPANS = 10_000
22
+ SPAN_BATCH = 1000
23
+ SPAN_FLUSH = 5.0
24
+ SENSITIVE_KEY = /pass(?:word|wd)?|pwd|secret|token|api[_-]?key|auth(?:orization)?|cookie|session|credit|card|cvv|ssn|csrf|private[_-]?key/i.freeze
25
+ SENSITIVE_PAIR = /(?<![\w.-])([\w.-]+)((?:\s*=\s*|["']\s*:\s*)["']?)(?!\[Filtered\])([^&\s"',;}]+)/.freeze
26
+ BEARER = %r{\b(Bearer|Basic|Token)\s+[\w.~+/=-]{8,}}.freeze
27
+ CARD_LIKE = /\b\d(?:[ -]?\d){12,18}\b/.freeze
28
+ KEEP = %w[event_id trace_id span_id parent_span_id].freeze
29
+ TAG_KEY = /\A[a-zA-Z0-9_.-]{1,64}\z/.freeze
30
+ LIB_DIRS = [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["rubyarchdir"]].compact.freeze
31
+
32
+ module_function
33
+
34
+ def luhn(digits)
35
+ sum = 0
36
+ digits.reverse.each_char.with_index do |ch, i|
37
+ d = ch.ord - 48
38
+ if i.odd?
39
+ d *= 2
40
+ d -= 9 if d > 9
41
+ end
42
+ sum += d
43
+ end
44
+ (sum % 10).zero?
45
+ end
46
+
47
+ def scrub_text(text)
48
+ out = text.gsub(BEARER) { "#{Regexp.last_match(1)} #{FILTERED}" }
49
+ if out.match?(SENSITIVE_KEY)
50
+ out = out.gsub(SENSITIVE_PAIR) do
51
+ m = Regexp.last_match
52
+ m[1].match?(SENSITIVE_KEY) ? "#{m[1]}#{m[2]}#{FILTERED}" : m[0]
53
+ end
54
+ end
55
+ out.gsub(CARD_LIKE) do |match|
56
+ digits = match.delete(" -")
57
+ digits.length.between?(13, 19) && luhn(digits) ? FILTERED : match
58
+ end
59
+ end
60
+
61
+ def scrub(value)
62
+ case value
63
+ when String then scrub_text(value)
64
+ when Array then value.map { |v| scrub(v) }
65
+ when Hash
66
+ value.each_with_object({}) do |(k, v), out|
67
+ name = k.to_s
68
+ out[k] = if KEEP.include?(name)
69
+ v
70
+ elsif name.match?(SENSITIVE_KEY) && !v.nil? && v != ""
71
+ FILTERED
72
+ else
73
+ scrub(v)
74
+ end
75
+ end
76
+ else value
77
+ end
78
+ end
79
+
80
+ def text(value, size)
81
+ return nil if value.nil?
82
+
83
+ out = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
84
+ out.empty? ? nil : out[0, size]
85
+ end
86
+
87
+ def redact_sql(sql)
88
+ sql.to_s.gsub(/'(?:[^']|'')*'/, "?").gsub(/(?<![\w$.])-?\d+(?:\.\d+)?(?![\w.])/, "?").gsub(/\(\s*\?(?:\s*,\s*\?)+\s*\)/, "(?)").gsub(/\s+/, " ").strip[0, 2000]
89
+ end
90
+
91
+ def sql_op(sql)
92
+ m = /\A\s*(\w+)/.match(sql.to_s)
93
+ m ? m[1].upcase : "QUERY"
94
+ end
95
+
96
+ def clean_url(raw)
97
+ uri = URI.parse(raw.to_s)
98
+ uri.user = nil
99
+ uri.password = nil
100
+ uri.query = nil
101
+ uri.fragment = nil
102
+ uri.to_s[0, 2048]
103
+ rescue StandardError
104
+ raw.to_s.split("?", 2).first.to_s[0, 2048]
105
+ end
106
+
107
+ def scrub_query(query)
108
+ query.to_s.split("&").map do |pair|
109
+ name = pair.split("=", 2).first.to_s
110
+ pair.include?("=") && name.match?(SENSITIVE_KEY) ? "#{name}=#{FILTERED}" : pair
111
+ end.join("&")[0, 2048]
112
+ end
113
+
114
+ def now_iso
115
+ Time.now.utc.iso8601(3)
116
+ end
117
+
118
+ def request_info(method, url, query, header)
119
+ out = { "url" => clean_url(url), "method" => (method || "GET").to_s[0, 16] }
120
+ out["query_string"] = scrub_query(query) if query && !query.empty?
121
+ headers = {}
122
+ HEADER_ALLOW.each do |name|
123
+ value = header.call(name)
124
+ headers[name] = value.to_s[0, 1024] if value && !value.to_s.empty?
125
+ end
126
+ out["headers"] = headers unless headers.empty?
127
+ out
128
+ end
129
+
130
+ def retry_after(header, body)
131
+ if header && !header.to_s.empty?
132
+ return [Integer(header.to_s, 10), 1].max.to_f if header.to_s.match?(/\A\d+\z/)
133
+
134
+ begin
135
+ return [Time.httpdate(header.to_s) - Time.now, 1].max.to_f
136
+ rescue ArgumentError
137
+ nil
138
+ end
139
+ end
140
+ wait = body.is_a?(Hash) ? body["retry_after"] : nil
141
+ wait.is_a?(Numeric) ? [wait.to_f, 1.0].max : 60.0
142
+ end
143
+
144
+ def security_event(kind, actor, fields)
145
+ kind = kind.to_s
146
+ level = WARNING_KINDS.include?(kind) ? "warning" : "info"
147
+ message = "#{kind} for #{actor}"
148
+ return { "message" => message, "level" => level, "tags" => { "security.kind" => kind } } unless SECURITY_KINDS.include?(kind)
149
+
150
+ security = { "kind" => kind, "actor" => actor.to_s.strip[0, 256] }
151
+ SECURITY_FIELDS.each do |field, size|
152
+ value = fields[field]
153
+ security[field.to_s] = value.to_s.strip[0, size] unless value.nil? || value.to_s.empty?
154
+ end
155
+ { "message" => message, "level" => level, "security" => security, "tags" => { "security.kind" => kind } }
156
+ end
157
+
158
+ class Scope
159
+ attr_accessor :user, :tags, :extra, :breadcrumbs, :request, :fingerprint
160
+
161
+ def initialize(parent = nil, max = 100)
162
+ @max = parent ? parent.max : max
163
+ @user = parent && parent.user ? parent.user.dup : nil
164
+ @tags = parent ? parent.tags.dup : {}
165
+ @extra = parent ? parent.extra.dup : {}
166
+ @breadcrumbs = parent ? parent.breadcrumbs.dup : []
167
+ @fingerprint = parent && parent.fingerprint ? parent.fingerprint.dup : nil
168
+ @request = nil
169
+ end
170
+
171
+ attr_reader :max
172
+
173
+ def add(crumb)
174
+ @breadcrumbs << crumb
175
+ @breadcrumbs.shift while @breadcrumbs.size > @max
176
+ end
177
+ end
178
+
179
+ Span = Struct.new(:trace_id, :span_id, :parent_id, :name, :kind, :start, :finish, :error, :attrs, :sampled)
180
+
181
+ class Client
182
+ attr_reader :options, :key, :own_host, :environment
183
+ attr_accessor :limited_until, :noticed
184
+
185
+ def initialize(key, endpoint, options)
186
+ @key = key
187
+ @base = endpoint.to_s.chomp("/")
188
+ uri = URI.parse(@base)
189
+ @own_host = (uri.port == uri.default_port ? uri.host.to_s : "#{uri.host}:#{uri.port}").downcase
190
+ @options = options
191
+ @environment = (options[:environment] || "production").to_s
192
+ @debug = options[:debug] ? true : false
193
+ @sample_rate = (options[:sample_rate] || 1.0).to_f
194
+ @traces_rate = [[options[:traces_sample_rate].to_f, 0.0].max, 1.0].min
195
+ @global = Scope.new(nil, [(options[:max_breadcrumbs] || 100).to_i, 100].min)
196
+ @limited_until = 0.0
197
+ @noticed = false
198
+ @sources = {}
199
+ reset_worker
200
+ end
201
+
202
+ def reset_worker
203
+ @mutex = Mutex.new
204
+ @cond = ConditionVariable.new
205
+ @queue = []
206
+ @busy = 0
207
+ @thread = nil
208
+ @spans = []
209
+ @spans_since = 0.0
210
+ @pid = Process.pid
211
+ end
212
+
213
+ def traces_rate
214
+ @traces_rate
215
+ end
216
+
217
+ def development?
218
+ @debug || @environment == "development"
219
+ end
220
+
221
+ def scope
222
+ Thread.current[:blipit_scope] || @global
223
+ end
224
+
225
+ def isolate
226
+ previous = Thread.current[:blipit_scope]
227
+ Thread.current[:blipit_scope] = Scope.new(scope)
228
+ previous
229
+ end
230
+
231
+ def restore(previous)
232
+ Thread.current[:blipit_scope] = previous
233
+ end
234
+
235
+ def add_breadcrumb(crumb, hint = {})
236
+ category = (crumb[:category] || crumb["category"] || "custom").to_s
237
+ mapped = if CATEGORIES.include?(category)
238
+ category
239
+ else
240
+ category == "db" || category.start_with?("sql") ? "query" : "custom"
241
+ end
242
+ out = { "timestamp" => now, "category" => mapped }
243
+ message = crumb[:message] || crumb["message"]
244
+ out["message"] = Nuggets.text(message, 1024) if message
245
+ level = LEVELS[(crumb[:level] || crumb["level"] || "info").to_s.downcase] || "info"
246
+ out["level"] = level == "fatal" ? "error" : level
247
+ data = (crumb[:data] || crumb["data"] || {}).to_h.dup
248
+ data["category"] ||= category[0, 64] if category != mapped
249
+ out["data"] = data unless data.empty?
250
+ before = @options[:before_breadcrumb]
251
+ if before
252
+ out = safely { before.call(out, hint) }
253
+ return if out.nil? || out == false
254
+ end
255
+ scope.add(out)
256
+ end
257
+
258
+ def exception_event(error, handled: true, level: "error")
259
+ return nil if seen?(error)
260
+
261
+ Array(@options[:excluded_exceptions]).each do |item|
262
+ return nil if (item.is_a?(Module) && error.is_a?(item)) || item.to_s == error.class.name
263
+ end
264
+ exc = { "type" => Nuggets.text(error.class.name, 256) || "Exception", "value" => Nuggets.text(error.message, 4096) || "", "handled" => handled }
265
+ frames = frames_of(error)
266
+ exc["stacktrace"] = { "frames" => frames } unless frames.empty?
267
+ event = { "exception" => exc, "level" => level }
268
+ chain = []
269
+ cause = error.cause
270
+ while cause && chain.size < 8
271
+ chain << { "type" => cause.class.name.to_s, "value" => cause.message.to_s[0, 1024] }
272
+ cause = cause.cause
273
+ end
274
+ event["extra"] = { "exception_chain" => chain } unless chain.empty?
275
+ event
276
+ end
277
+
278
+ def seen?(error)
279
+ return true if error.instance_variable_defined?(:@__blipit_seen)
280
+
281
+ error.instance_variable_set(:@__blipit_seen, true)
282
+ false
283
+ rescue StandardError
284
+ false
285
+ end
286
+
287
+ def frames_of(error)
288
+ locations = error.backtrace_locations
289
+ frames = if locations
290
+ locations.map { |loc| frame(loc.absolute_path || loc.path, loc.lineno, loc.label) }
291
+ else
292
+ Array(error.backtrace).map do |line|
293
+ m = /\A(.+?):(\d+)(?::in [`'](.*)')?\z/.match(line)
294
+ m ? frame(m[1], m[2].to_i, m[3]) : nil
295
+ end.compact
296
+ end
297
+ frames.reverse.last(128)
298
+ end
299
+
300
+ def frame(path, lineno, label)
301
+ path = path.to_s
302
+ out = { "filename" => short(path)[0, 1024], "lineno" => [lineno.to_i, 0].max, "in_app" => in_app?(path) }
303
+ out["function"] = label.to_s[0, 512] if label
304
+ lines = source(path)
305
+ if lines && lineno.to_i.positive? && lineno.to_i <= lines.size
306
+ i = lineno.to_i - 1
307
+ out["context_line"] = lines[i].chomp[0, 512]
308
+ out["pre_context"] = lines[[i - 5, 0].max...i].map { |l| l.chomp[0, 512] }
309
+ out["post_context"] = (lines[(i + 1)..(i + 5)] || []).map { |l| l.chomp[0, 512] }
310
+ end
311
+ out
312
+ end
313
+
314
+ def source(path)
315
+ return nil if @options[:include_source_context] == false || path.start_with?("<")
316
+ return @sources[path] if @sources.key?(path)
317
+
318
+ @sources.shift if @sources.size >= 50
319
+ @sources[path] = File.file?(path) && File.size(path) < 1_000_000 ? File.readlines(path).map { |l| l.force_encoding(Encoding::UTF_8).scrub } : nil
320
+ rescue StandardError
321
+ @sources[path] = nil
322
+ end
323
+
324
+ def in_app?(path)
325
+ return false if path.empty? || path.start_with?("<") || path.include?("/gems/")
326
+
327
+ LIB_DIRS.none? { |dir| path.start_with?(dir) }
328
+ end
329
+
330
+ def short(path)
331
+ return path.split("/gems/", 2).last if path.include?("/gems/")
332
+
333
+ cwd = Dir.pwd
334
+ path.start_with?("#{cwd}/") ? path[(cwd.size + 1)..-1] : path
335
+ end
336
+
337
+ def capture(partial, hint = {})
338
+ return nil if partial.nil?
339
+ return nil if partial["security"].nil? && @sample_rate < 1.0 && rand >= @sample_rate
340
+ return nil if monotonic < @limited_until
341
+
342
+ current = scope
343
+ event = {
344
+ "event_id" => SecureRandom.hex(16),
345
+ "timestamp" => now,
346
+ "platform" => "ruby",
347
+ "level" => "error",
348
+ "environment" => @environment[0, 64],
349
+ "contexts" => {
350
+ "runtime" => { "name" => RUBY_ENGINE, "version" => RUBY_VERSION },
351
+ "os" => { "name" => RbConfig::CONFIG["host_os"].to_s, "version" => "" }
352
+ },
353
+ "sdk" => { "name" => SDK_NAME, "version" => Blipit::VERSION }
354
+ }
355
+ event["release"] = Nuggets.text(@options[:release], 256) if @options[:release]
356
+ tags = {}
357
+ tags["server"] = @options[:server_name].to_s if @options[:server_name]
358
+ tags.merge!(current.tags)
359
+ tags.merge!(partial.delete("tags") || {})
360
+ span = Thread.current[:blipit_span]
361
+ tags["trace_id"] = span.trace_id if span && span.sampled
362
+ clean = {}
363
+ tags.each do |k, v|
364
+ clean[k.to_s] = v.to_s[0, 256] if k.to_s.match?(TAG_KEY) && !v.nil? && clean.size < 32
365
+ end
366
+ event["tags"] = clean unless clean.empty?
367
+ extra = current.extra.merge(partial.delete("extra") || {})
368
+ event["extra"] = extra unless extra.empty?
369
+ user = partial.delete("user") || current.user
370
+ if user
371
+ u = {}
372
+ %w[id email username].each do |field|
373
+ value = user[field.to_sym] || user[field]
374
+ u[field] = value.to_s[0, field == "email" ? 256 : 128] unless value.nil? || value.to_s.empty?
375
+ end
376
+ event["user"] = u unless u.empty?
377
+ end
378
+ event["breadcrumbs"] = current.breadcrumbs.last(100) unless current.breadcrumbs.empty?
379
+ event["request"] = current.request.dup if current.request
380
+ fingerprint = partial.delete("fingerprint") || current.fingerprint
381
+ event["fingerprint"] = Array(fingerprint).first(8).map { |f| f.to_s[0, 256] } if fingerprint
382
+ event.merge!(partial)
383
+ event["message"] = Nuggets.text(event["message"], 8192) if event.key?("message")
384
+ event = Nuggets.scrub(event.reject { |_, v| v.nil? })
385
+ before = @options[:before_send]
386
+ if before
387
+ event = safely(event) { before.call(event, hint) }
388
+ return nil unless event
389
+ end
390
+ submit(:event, event)
391
+ event["event_id"]
392
+ end
393
+
394
+ def submit(kind, item)
395
+ reset_worker if Process.pid != @pid
396
+ @mutex.synchronize do
397
+ if kind == :event && @queue.count { |k, _| k == :event } >= MAX_EVENTS
398
+ index = @queue.index { |k, _| k == :event }
399
+ @queue.delete_at(index) if index
400
+ end
401
+ @queue << [kind, item]
402
+ wake
403
+ end
404
+ end
405
+
406
+ def wake
407
+ @thread = Thread.new { run } if @thread.nil? || !@thread.alive?
408
+ @cond.broadcast
409
+ end
410
+
411
+ def run
412
+ Thread.current[:blipit_internal] = true
413
+ Thread.current.name = "blipit-worker" if Thread.current.respond_to?(:name=)
414
+ loop do
415
+ kind = nil
416
+ item = nil
417
+ @mutex.synchronize do
418
+ while @queue.empty?
419
+ if !@spans.empty? && monotonic - @spans_since >= SPAN_FLUSH
420
+ move_spans
421
+ next
422
+ end
423
+ @cond.wait(@mutex, @spans.empty? ? 30 : 1)
424
+ return if @queue.empty? && @spans.empty?
425
+ end
426
+ kind, item = @queue.shift
427
+ @busy += 1
428
+ end
429
+ begin
430
+ kind == :event ? deliver("/api/v1/events", item, true) : deliver("/v1/traces", otlp(item), false)
431
+ rescue StandardError => e
432
+ debug("[blipit] send failed: #{e.class}: #{e.message}")
433
+ ensure
434
+ @mutex.synchronize do
435
+ @busy -= 1
436
+ @cond.broadcast
437
+ end
438
+ end
439
+ end
440
+ end
441
+
442
+ def deliver(path, body, event)
443
+ 3.times do |attempt|
444
+ return if monotonic < @limited_until
445
+
446
+ status, headers, payload = post(path, body)
447
+ if status == 429
448
+ @limited_until = monotonic + Nuggets.retry_after(headers["retry-after"], payload)
449
+ debug("[blipit] rate limited, pausing sends")
450
+ return
451
+ end
452
+ if status.positive? && status < 500
453
+ debug("[blipit] #{path} rejected #{status} #{payload}") if status >= 400
454
+ notice(payload["latest"]) if event && status == 202 && development? && payload.is_a?(Hash)
455
+ return
456
+ end
457
+ sleep(0.5 * (2**attempt))
458
+ end
459
+ end
460
+
461
+ def post(path, body)
462
+ uri = URI.parse(@base + path)
463
+ http = Net::HTTP.new(uri.host, uri.port)
464
+ http.use_ssl = uri.scheme == "https"
465
+ http.open_timeout = 5
466
+ http.read_timeout = (@options[:http_timeout] || 5).to_f
467
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "X-Blipit-Key" => @key, "User-Agent" => "#{SDK_NAME}/#{Blipit::VERSION}")
468
+ request.body = JSON.generate(body)
469
+ response = http.start { |h| h.request(request) }
470
+ parsed = begin
471
+ JSON.parse(response.body.to_s)
472
+ rescue JSON::ParserError
473
+ nil
474
+ end
475
+ [response.code.to_i, { "retry-after" => response["retry-after"] }, parsed]
476
+ rescue StandardError => e
477
+ debug("[blipit] #{path} not sent: #{e.class}: #{e.message}")
478
+ [0, {}, nil]
479
+ end
480
+
481
+ def notice(latest)
482
+ return if @noticed
483
+
484
+ line = Blipit.notice_line(latest)
485
+ return unless line
486
+
487
+ @noticed = true
488
+ warn(line)
489
+ end
490
+
491
+ def flush(timeout = 2)
492
+ deadline = monotonic + timeout.to_f
493
+ @mutex.synchronize do
494
+ move_spans
495
+ while (!@queue.empty? || @busy.positive?) && monotonic < deadline
496
+ wake
497
+ @cond.wait(@mutex, [deadline - monotonic, 0.01].max)
498
+ end
499
+ end
500
+ end
501
+
502
+ def move_spans
503
+ @queue << [:spans, @spans.shift(SPAN_BATCH)] until @spans.empty?
504
+ end
505
+
506
+ def start_span(name, kind, attrs, root: false)
507
+ return nil if @traces_rate <= 0
508
+
509
+ parent = root ? nil : Thread.current[:blipit_span]
510
+ return nil if parent.nil? && !root
511
+
512
+ sampled = parent ? parent.sampled : rand < @traces_rate
513
+ return Span.new("", "", nil, name, kind, 0, 0, false, {}, false) unless sampled
514
+
515
+ Span.new(parent ? parent.trace_id : SecureRandom.hex(16), SecureRandom.hex(8), parent ? parent.span_id : nil, name, kind, now_ns, 0, false, attrs, true)
516
+ end
517
+
518
+ def finish_span(span, error = false, start_ns: nil)
519
+ return if span.nil? || !span.sampled
520
+
521
+ span.finish = now_ns
522
+ span.start = start_ns if start_ns
523
+ span.error ||= error
524
+ reset_worker if Process.pid != @pid
525
+ @mutex.synchronize do
526
+ @spans_since = monotonic if @spans.empty?
527
+ @spans << span if @spans.size < MAX_SPANS
528
+ move_spans if @spans.size >= SPAN_BATCH
529
+ wake
530
+ end
531
+ end
532
+
533
+ def resource
534
+ host = Socket.gethostname
535
+ pairs = [
536
+ ["service.name", @options[:server_name] || ENV["OTEL_SERVICE_NAME"] || "ruby"],
537
+ ["host.name", host],
538
+ ["service.instance.id", "#{host}:#{Process.pid}"],
539
+ ["deployment.environment", @environment],
540
+ ["service.version", @options[:release]],
541
+ ["process.runtime.version", RUBY_VERSION],
542
+ ["telemetry.sdk.name", SDK_NAME]
543
+ ]
544
+ pairs.reject { |_, v| v.nil? || v.to_s.empty? }.map { |k, v| { "key" => k, "value" => { "stringValue" => v.to_s } } }
545
+ end
546
+
547
+ def otlp(spans)
548
+ attr = lambda do |k, v|
549
+ value = case v
550
+ when true, false then { "boolValue" => v }
551
+ when Integer then { "intValue" => v }
552
+ when Float then { "doubleValue" => v }
553
+ else { "stringValue" => v.to_s }
554
+ end
555
+ { "key" => k, "value" => value }
556
+ end
557
+ rows = spans.map do |s|
558
+ row = {
559
+ "traceId" => s.trace_id,
560
+ "spanId" => s.span_id,
561
+ "name" => s.name.to_s[0, 512],
562
+ "kind" => s.kind,
563
+ "startTimeUnixNano" => s.start.to_s,
564
+ "endTimeUnixNano" => s.finish.to_s,
565
+ "attributes" => Nuggets.scrub(s.attrs).map { |k, v| attr.call(k, v) } + [attr.call("blipit.sample_rate", @traces_rate)],
566
+ "status" => { "code" => s.error ? 2 : 1 }
567
+ }
568
+ row["parentSpanId"] = s.parent_id if s.parent_id
569
+ row
570
+ end
571
+ { "resourceSpans" => [{ "resource" => { "attributes" => resource }, "scopeSpans" => [{ "scope" => { "name" => SDK_NAME }, "spans" => rows }] }] }
572
+ end
573
+
574
+ def debug(line)
575
+ warn(line) if @debug
576
+ end
577
+
578
+ private
579
+
580
+ def safely(fallback = nil)
581
+ yield
582
+ rescue StandardError => e
583
+ warn("[blipit] a callback raised #{e.class}: #{e.message}")
584
+ fallback
585
+ end
586
+
587
+ def now
588
+ Nuggets.now_iso
589
+ end
590
+
591
+ def now_ns
592
+ Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
593
+ end
594
+
595
+ def monotonic
596
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
597
+ end
598
+ end
599
+ end
600
+ end
@@ -1,3 +1,3 @@
1
1
  module Blipit
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/blipit.rb CHANGED
@@ -1,15 +1,8 @@
1
- require "sentry-ruby"
2
1
  require "json"
3
2
  require "uri"
4
3
  require_relative "blipit/version"
5
-
6
- if defined?(::Rails::Railtie)
7
- begin
8
- require "sentry-rails"
9
- rescue LoadError
10
- nil
11
- end
12
- end
4
+ require_relative "blipit/nuggets"
5
+ require_relative "blipit/integrations"
13
6
 
14
7
  module Blipit
15
8
  DEFAULT_ENDPOINT = "https://in.blipit.io"
@@ -20,158 +13,156 @@ module Blipit
20
13
  MISSING_KEY_WARNING = "[blipit] Blipit.init got no key, so Blipit is off and nothing is sent. " \
21
14
  "Pass the project's key (app.blipit.io, Keys), for example from ENV[\"BLIPIT_KEY\"]; " \
22
15
  "with Rails config caching, check that the value is set where the app boots."
23
- MISSING_PROJECT_WARNING = "[blipit] Blipit.init got no project id, so Blipit is off and nothing is sent. " \
24
- "Pass project: with the id from the project's Keys page at app.blipit.io."
25
- RAILS_WARNING = "[blipit] Rails is loaded but sentry-rails is not, so errors raised in controllers and jobs are not reported " \
26
- "(Rails rescues them before they reach Ruby). Add gem \"sentry-rails\" to your Gemfile next to gem \"blipit\" " \
27
- "and run bundle install."
28
16
  MISSING_ACTOR_WARNING = "[blipit] capture_security needs a kind and an actor, so this login attempt was not sent."
29
17
  UPDATE_COMMAND = "bundle update blipit"
30
- PACKAGE = { "name" => "gem:blipit", "version" => VERSION }.freeze
31
-
32
- if Sentry::HTTPTransport.method_defined?(:do_request)
33
- class NoticeTransport < Sentry::HTTPTransport
34
- def do_request(*args)
35
- response = super
36
- Blipit.notice(response)
37
- response
18
+
19
+ class Sink
20
+ def method_missing(_name, *_args)
21
+ self
22
+ end
23
+
24
+ def respond_to_missing?(_name, _include_private = false)
25
+ true
26
+ end
27
+ end
28
+
29
+ class Config
30
+ def initialize(options)
31
+ @options = options
32
+ end
33
+
34
+ attr_reader :options
35
+
36
+ def method_missing(name, *args)
37
+ key = name.to_s
38
+ if key.end_with?("=")
39
+ @options[key.chomp("=").to_sym] = args.first
40
+ elsif @options.key?(name)
41
+ @options[name]
42
+ else
43
+ Sink.new
38
44
  end
39
45
  end
46
+
47
+ def respond_to_missing?(_name, _include_private = false)
48
+ true
49
+ end
40
50
  end
41
51
 
42
52
  class << self
43
- def dsn(key, project, endpoint = DEFAULT_ENDPOINT)
44
- scheme = endpoint.start_with?("http://") ? "http" : "https"
45
- host = endpoint.sub(%r{\A[a-z]+://}i, "").chomp("/")
46
- "#{scheme}://#{key}@#{host}/#{project}"
47
- end
53
+ attr_reader :client
48
54
 
49
- def init(key:, project:, environment: nil, release: nil, traces_sample_rate: 0.0, endpoint: DEFAULT_ENDPOINT, **options)
55
+ def init(key:, project: nil, environment: nil, release: nil, traces_sample_rate: 0.0, endpoint: DEFAULT_ENDPOINT, **options)
50
56
  if key.nil? || key.to_s.strip.empty?
51
57
  warn(MISSING_KEY_WARNING)
52
58
  return false
53
59
  end
54
- if project.nil? || project.to_s.strip.empty?
55
- warn(MISSING_PROJECT_WARNING)
60
+ options = options.merge(environment: environment, release: release, traces_sample_rate: traces_sample_rate)
61
+ config = Config.new(options)
62
+ yield config if block_given?
63
+ options = config.options
64
+ enabled = options[:enabled_environments]
65
+ if enabled.is_a?(Array) && !enabled.empty? && !enabled.map(&:to_s).include?((options[:environment] || "production").to_s)
66
+ @client = nil
56
67
  return false
57
68
  end
58
- @public_key = key.to_s.start_with?("blipit_pk_")
59
- warn(RAILS_WARNING) if defined?(::Rails::Railtie) && !defined?(::Sentry::Rails)
60
-
61
- Sentry.init do |config|
62
- config.dsn = dsn(key, project, endpoint)
63
- config.environment = environment if environment
64
- config.release = release if release
65
- config.traces_sample_rate = traces_sample_rate
66
- config.send_default_pii = false
67
- options.each { |name, value| config.public_send("#{name}=", value) }
68
- yield config if block_given?
69
- user_before_send = config.before_send
70
- config.before_send = lambda do |event, hint|
71
- with_identity(event)
72
- user_before_send ? user_before_send.call(event, hint) : event
73
- end
74
- if (config.environment == "development" || config.debug) && config.transport.transport_class.nil? && defined?(NoticeTransport)
75
- config.transport.transport_class = NoticeTransport
76
- end
77
- end
69
+ @client&.flush(0.5)
70
+ key = key.to_s.strip
71
+ @public_key = key.start_with?("blipit_pk_")
72
+ @public_key_warned = false
73
+ @client = Nuggets::Client.new(key, endpoint, options)
74
+ Integrations.install(options)
78
75
  true
79
76
  end
80
77
 
81
- def update_notice(header, current = VERSION)
82
- latest = JSON.parse(URI.decode_www_form_component(header.to_s.gsub("+", "%2B")))
83
- version = latest.is_a?(Hash) ? latest["version"] : nil
78
+ def notice_line(latest, current = VERSION)
79
+ return nil unless latest.is_a?(Hash)
80
+
81
+ version = latest["version"]
84
82
  return nil unless version.is_a?(String) && !version.empty? && version != current
85
83
 
86
84
  note = latest["note"].is_a?(String) ? latest["note"].strip : ""
87
85
  why = note.empty? ? "" : " — #{note}"
88
86
  "[blipit] #{version} is available (you have #{current})#{why} — #{UPDATE_COMMAND}"
89
- rescue StandardError
90
- nil
91
87
  end
92
88
 
93
- def notice(response)
94
- return if @noticed
95
-
96
- header = response["x-blipit-latest"]
97
- line = header && update_notice(header)
98
- return unless line
99
-
100
- @noticed = true
101
- warn(line)
89
+ def update_notice(header, current = VERSION)
90
+ notice_line(JSON.parse(URI.decode_www_form_component(header.to_s.gsub("+", "%2B"))), current)
102
91
  rescue StandardError
103
92
  nil
104
93
  end
105
94
 
106
- def with_identity(event)
107
- sdk = event.sdk.is_a?(Hash) ? event.sdk : {}
108
- packages = Array(sdk["packages"] || sdk[:packages])
109
- return if packages.any? { |p| p.is_a?(Hash) && (p["name"] || p[:name]) == PACKAGE["name"] }
95
+ def capture_exception(error, **options)
96
+ return nil unless @client
110
97
 
111
- event.sdk = sdk.merge("packages" => packages + [PACKAGE.dup])
112
- rescue StandardError
113
- nil
114
- end
98
+ event = @client.exception_event(error, handled: true, level: (options[:level] || "error").to_s)
99
+ return nil unless event
115
100
 
116
- def capture_exception(error, **options)
117
- Sentry.capture_exception(error, **options)
101
+ @client.capture(event.merge(extras(options)), { exception: error })
118
102
  end
119
103
 
120
104
  def capture_message(message, level: :info, **options)
121
- Sentry.capture_message(message, level: level, **options)
105
+ return nil unless @client
106
+
107
+ event = { "message" => message.to_s, "level" => Nuggets::LEVELS[level.to_s.downcase] || "info" }
108
+ @client.capture(event.merge(extras(options)))
122
109
  end
123
110
 
124
111
  def set_user(user)
125
- Sentry.set_user(user)
112
+ @client.scope.user = user ? user.to_h : nil if @client
126
113
  end
127
114
 
128
115
  def set_tag(key, value)
129
- Sentry.set_tags(key => value)
116
+ @client.scope.tags[key.to_s[0, 64]] = value.to_s[0, 256] if @client
117
+ end
118
+
119
+ def set_tags(tags)
120
+ tags.each { |k, v| set_tag(k, v) }
121
+ end
122
+
123
+ def set_extras(extras)
124
+ @client.scope.extra.merge!(extras.transform_keys(&:to_s)) if @client
125
+ end
126
+
127
+ def set_context(name, value)
128
+ return unless @client
129
+
130
+ value.nil? ? @client.scope.extra.delete(name.to_s) : @client.scope.extra[name.to_s] = value
130
131
  end
131
132
 
132
133
  def add_breadcrumb(message, category: nil, level: :info, data: {})
133
- Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: message, category: category, level: level, data: data))
134
+ @client&.add_breadcrumb({ message: message, category: category || "custom", level: level, data: data })
134
135
  end
135
136
 
136
- def capture_security(kind_arg = nil, actor_arg = nil, kind: nil, actor: nil, outcome: nil, actor_id: nil, ip: nil, user_agent: nil, target: nil)
137
+ def capture_security(kind_arg = nil, actor_arg = nil, kind: nil, actor: nil, outcome: nil, actor_id: nil, ip: nil, user_agent: nil, target: nil, database: nil)
137
138
  kind ||= kind_arg
138
139
  actor ||= actor_arg
139
140
  if kind.to_s.empty? || actor.to_s.strip.empty?
140
141
  warn(MISSING_ACTOR_WARNING)
141
142
  return
142
143
  end
144
+ return nil unless @client
145
+
143
146
  if @public_key
144
147
  warn(PUBLIC_KEY_WARNING) unless @public_key_warned
145
148
  @public_key_warned = true
146
149
  return
147
150
  end
148
- payload = {
149
- kind: kind,
150
- actor: actor,
151
- outcome: outcome,
152
- actor_id: actor_id,
153
- ip: ip,
154
- user_agent: user_agent,
155
- target: target
156
- }.compact
157
- level = WARNING_KINDS.include?(kind.to_s) ? :warning : :info
158
- Sentry.with_scope do |scope|
159
- scope.set_context("security", payload)
160
- scope.set_tag("security.kind", kind)
161
- Sentry.capture_message("#{kind} for #{actor}", level: level)
162
- end
151
+ fields = { outcome: outcome, actor_id: actor_id, ip: ip, user_agent: user_agent, target: target, database: database }
152
+ @client.capture(Nuggets.security_event(kind, actor, fields))
163
153
  end
164
154
 
165
155
  def flush(timeout = 2)
166
- client = Sentry.get_current_client
167
- return unless client
156
+ @client&.flush(timeout)
157
+ end
168
158
 
169
- executor = Sentry.background_worker&.instance_variable_get(:@executor)
170
- if executor.respond_to?(:scheduled_task_count)
171
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
172
- sleep 0.01 while executor.scheduled_task_count > executor.completed_task_count && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
173
- end
174
- client.flush
159
+ private
160
+
161
+ def extras(options)
162
+ out = {}
163
+ %i[tags extra user fingerprint].each { |k| out[k.to_s] = options[k] if options[k] }
164
+ out["extra"] = (out["extra"] || {}).merge(options[:contexts].transform_keys(&:to_s)) if options[:contexts].is_a?(Hash)
165
+ out
175
166
  end
176
167
  end
177
168
  end
metadata CHANGED
@@ -1,55 +1,27 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blipit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Louward Labs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-12 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: logger
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '0'
27
- - !ruby/object:Gem::Dependency
28
- name: sentry-ruby
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '5.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '5.0'
41
13
  - !ruby/object:Gem::Dependency
42
14
  name: minitest
43
15
  requirement: !ruby/object:Gem::Requirement
44
16
  requirements:
45
- - - "~>"
17
+ - - ">="
46
18
  - !ruby/object:Gem::Version
47
19
  version: '5.0'
48
20
  type: :development
49
21
  prerelease: false
50
22
  version_requirements: !ruby/object:Gem::Requirement
51
23
  requirements:
52
- - - "~>"
24
+ - - ">="
53
25
  - !ruby/object:Gem::Version
54
26
  version: '5.0'
55
27
  - !ruby/object:Gem::Dependency
@@ -76,6 +48,8 @@ files:
76
48
  - CHANGELOG.md
77
49
  - README.md
78
50
  - lib/blipit.rb
51
+ - lib/blipit/integrations.rb
52
+ - lib/blipit/nuggets.rb
79
53
  - lib/blipit/version.rb
80
54
  homepage: https://blipit.io
81
55
  licenses: