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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +57 -11
- data/lib/blipit/integrations.rb +267 -0
- data/lib/blipit/nuggets.rb +600 -0
- data/lib/blipit/version.rb +1 -1
- data/lib/blipit.rb +93 -102
- metadata +6 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08dd6a2a074cf2d5363dc7a1640afb6e95605dcc8cf142319d788aabaf46c877'
|
|
4
|
+
data.tar.gz: f07cb8341c0c1c9a2d6f2c3946dbcb25cbead6fded1f24b0a9e0dd7bc494ca38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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>",
|
|
22
|
+
Blipit.init(key: "<public key>", environment: "production", release: "myapp@1.4.2")
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
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
|
-
|
|
39
|
+
Nothing else to install. Put the `Blipit.init` call in `config/initializers/blipit.rb`:
|
|
28
40
|
|
|
29
41
|
```ruby
|
|
30
|
-
|
|
31
|
-
gem "sentry-rails"
|
|
42
|
+
Blipit.init(key: ENV["BLIPIT_KEY"], environment: Rails.env, release: ENV["GIT_SHA"])
|
|
32
43
|
```
|
|
33
44
|
|
|
34
|
-
|
|
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
|
|
50
|
+
use Blipit::Rack
|
|
38
51
|
```
|
|
39
52
|
|
|
40
|
-
|
|
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
|
-
##
|
|
79
|
+
## Options
|
|
64
80
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
data/lib/blipit/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
@
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
107
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
@client.scope.user = user ? user.to_h : nil if @client
|
|
126
113
|
end
|
|
127
114
|
|
|
128
115
|
def set_tag(key, value)
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
167
|
-
|
|
156
|
+
@client&.flush(timeout)
|
|
157
|
+
end
|
|
168
158
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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-
|
|
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:
|