snare-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +68 -0
- data/lib/snare/capture.rb +37 -0
- data/lib/snare/queue.rb +74 -0
- data/lib/snare/transport.rb +51 -0
- data/lib/snare.rb +81 -0
- metadata +42 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bae212201b35791154f89443bdc6f115fd6e51bdf77c4b5fc66b1d8b525c762a
|
|
4
|
+
data.tar.gz: e9849e728c5dd643d299d131f06d496927fe771c2ffe65786dd9b88f323d35b9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a0ddb5a5f715e53209f855a6795203f5f5c13973538d0f652154cadcd78122ef0a01793f25d52c1dd1f7937ef78c4591ddd143d2e5afe84f2039494d20dd822b
|
|
7
|
+
data.tar.gz: 5c46bdeab23cce0c8a572ec25af2d0d56936402b218d248a5aaab0af3a58c7a0feeed7e6095518f0c316ce46d9831a3c5c12580b162ec1b0fdcb7dbb6392ca47
|
data/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# snare-sdk
|
|
2
|
+
|
|
3
|
+
Ruby SDK for [Snare](https://snare.dev) — captures unhandled exceptions and
|
|
4
|
+
manually-reported errors, batches them, and reports them to your Snare
|
|
5
|
+
project.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Not yet published to RubyGems. In this repo, add to a `Gemfile`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "snare-sdk", path: "packages/sdk-ruby"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "snare"
|
|
19
|
+
|
|
20
|
+
Snare.init(project_id: "proj_abc123", api_key: "snare_sdk_...", api_base: "https://intake.snare.dev")
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
risky_call
|
|
24
|
+
rescue => e
|
|
25
|
+
Snare.capture_exception(e)
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `Snare.init(project_id:, api_key:, api_base: "https://intake.snare.dev")`
|
|
30
|
+
registers crash-capture handling and starts the batching queue. Safe to
|
|
31
|
+
call more than once — each call re-initializes cleanly.
|
|
32
|
+
- `Snare.capture_exception(exception)` reports an exception from your own
|
|
33
|
+
`rescue` blocks. `exception` defaults to `$!`, so it can also be called
|
|
34
|
+
bare from inside a live `rescue` block: `rescue => e; Snare.capture_exception; end`.
|
|
35
|
+
- `Snare.flush` forces immediate delivery of whatever is currently queued —
|
|
36
|
+
useful in a graceful-shutdown hook.
|
|
37
|
+
|
|
38
|
+
Events are batched and flushed after 5 events are queued, or 10 seconds
|
|
39
|
+
after the oldest queued event, whichever comes first. Delivery is
|
|
40
|
+
best-effort and silent: a dropped batch (network error, timeout, non-2xx
|
|
41
|
+
response) never raises into your application.
|
|
42
|
+
|
|
43
|
+
## Known limitation: background threads
|
|
44
|
+
|
|
45
|
+
Unhandled exceptions are captured via an `at_exit` hook that checks `$!`,
|
|
46
|
+
the same approach Sentry's own Ruby SDK uses. This only catches exceptions
|
|
47
|
+
that propagate all the way to the top of the **main** thread.
|
|
48
|
+
|
|
49
|
+
It will **not** automatically catch an exception that kills a background
|
|
50
|
+
`Thread` — Ruby raises those silently by default (unless
|
|
51
|
+
`Thread.report_on_exception` is set), and there's no clean, generic hook to
|
|
52
|
+
intercept them. If you spawn background threads and want their exceptions
|
|
53
|
+
reported, call `Snare.capture_exception` yourself inside a `rescue` in that
|
|
54
|
+
thread's body.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
ruby packages/sdk-ruby/verify_capture.rb
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
runs the verify script — every line should print `PASS`.
|
|
63
|
+
|
|
64
|
+
## Non-goals (v1)
|
|
65
|
+
|
|
66
|
+
Publishing to RubyGems; Rails-specific middleware/railtie integration;
|
|
67
|
+
automatically capturing exceptions that kill background threads;
|
|
68
|
+
console/log capture.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snare
|
|
4
|
+
# Pure event-building logic — mirrors packages/sdk/src/capture.ts. Kept free
|
|
5
|
+
# of any I/O so it's testable without a live queue/transport.
|
|
6
|
+
module Capture
|
|
7
|
+
PLATFORM = "ruby"
|
|
8
|
+
|
|
9
|
+
# Must stay in sync with services/intake/src/routes/telemetry.ts's
|
|
10
|
+
# capturedEventSchema (message.max(2000), stack.max(20_000)) — the server
|
|
11
|
+
# rejects the ENTIRE batch with a 400 if any single event exceeds these
|
|
12
|
+
# caps, and the transport never inspects the response status, so an
|
|
13
|
+
# un-truncated field here means silently dropped error reports.
|
|
14
|
+
MESSAGE_MAX_LENGTH = 2000
|
|
15
|
+
STACK_MAX_LENGTH = 20_000
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Per the wire protocol (docs/snare-multi-language-sdk-wire-protocol.md,
|
|
20
|
+
# Section 1): no url/userAgent keys for non-browser platforms, platform
|
|
21
|
+
# is always the literal "ruby", and consoleLog is a required key (nil is
|
|
22
|
+
# fine — console/log capture is optional for v1).
|
|
23
|
+
def build_captured_event(exception:, now:, console_log: nil)
|
|
24
|
+
event = {
|
|
25
|
+
message: exception.message.to_s[0, MESSAGE_MAX_LENGTH],
|
|
26
|
+
timestamp: now,
|
|
27
|
+
console_log: console_log,
|
|
28
|
+
platform: PLATFORM,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
stack = exception.backtrace&.join("\n")
|
|
32
|
+
event[:stack] = stack[0, STACK_MAX_LENGTH] if stack
|
|
33
|
+
|
|
34
|
+
event
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/snare/queue.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snare
|
|
4
|
+
# A small, dependency-free, thread-safe batching queue — mirrors
|
|
5
|
+
# packages/sdk/src/queue.ts. Flushes on flush_size events queued, or
|
|
6
|
+
# flush_interval seconds elapsed since the OLDEST queued event (the timer
|
|
7
|
+
# is armed once, when the queue transitions from empty to non-empty, and
|
|
8
|
+
# cancelled whenever the queue empties out).
|
|
9
|
+
#
|
|
10
|
+
# Unlike the JS SDK's single-threaded browser environment, Ruby apps
|
|
11
|
+
# (Rails, Sidekiq workers) are commonly multi-threaded, so every mutation
|
|
12
|
+
# of the internal array is guarded by a Mutex.
|
|
13
|
+
class Queue
|
|
14
|
+
def initialize(flush_size:, flush_interval:, &on_flush)
|
|
15
|
+
@flush_size = flush_size
|
|
16
|
+
@flush_interval = flush_interval
|
|
17
|
+
@on_flush = on_flush
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@queue = []
|
|
20
|
+
@timer_thread = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enqueue(event)
|
|
24
|
+
batch = nil
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@queue << event
|
|
27
|
+
start_timer if @queue.length == 1
|
|
28
|
+
batch = take_and_clear if @queue.length >= @flush_size
|
|
29
|
+
end
|
|
30
|
+
@on_flush.call(batch) if batch
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Forces a flush now (calls on_flush) if anything is queued. A no-op on
|
|
34
|
+
# an empty queue.
|
|
35
|
+
def flush
|
|
36
|
+
batch = @mutex.synchronize { take_and_clear if @queue.any? }
|
|
37
|
+
@on_flush.call(batch) if batch
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clears the queue and cancels the pending timer WITHOUT calling
|
|
41
|
+
# on_flush — for callers that want to deliver the drained events
|
|
42
|
+
# themselves via a different path.
|
|
43
|
+
def drain
|
|
44
|
+
@mutex.synchronize { take_and_clear }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Must be called while holding @mutex.
|
|
50
|
+
def take_and_clear
|
|
51
|
+
batch = @queue
|
|
52
|
+
@queue = []
|
|
53
|
+
cancel_timer
|
|
54
|
+
batch
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def start_timer
|
|
58
|
+
@timer_thread = Thread.new do
|
|
59
|
+
sleep @flush_interval
|
|
60
|
+
flush
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Must be called while holding @mutex. Never kills the current thread —
|
|
65
|
+
# when the timer thread's own sleep completes and it calls flush(),
|
|
66
|
+
# cancel_timer runs on that same thread; Thread#kill on the thread
|
|
67
|
+
# executing it terminates immediately and would never return control to
|
|
68
|
+
# flush()'s caller.
|
|
69
|
+
def cancel_timer
|
|
70
|
+
@timer_thread.kill if @timer_thread && @timer_thread != Thread.current
|
|
71
|
+
@timer_thread = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Snare
|
|
8
|
+
# HTTP delivery — mirrors packages/sdk/src/transport.ts. The API key rides
|
|
9
|
+
# in the JSON body, never a header (docs/snare-multi-language-sdk-wire-protocol.md,
|
|
10
|
+
# Section 1) — every language SDK follows the same envelope regardless of
|
|
11
|
+
# what's idiomatic for that language's HTTP client.
|
|
12
|
+
module Transport
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Never raises into the host application — network errors, timeouts, and
|
|
16
|
+
# non-2xx responses are all best-effort, silent failures. A dropped
|
|
17
|
+
# telemetry batch must never surface as an error in the host app.
|
|
18
|
+
def send_batch(api_base:, project_id:, api_key:, events:)
|
|
19
|
+
return if events.empty?
|
|
20
|
+
|
|
21
|
+
uri = URI.parse("#{api_base.chomp("/")}/telemetry/#{project_id}/events")
|
|
22
|
+
body = JSON.generate(build_body(api_key, events))
|
|
23
|
+
|
|
24
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
|
25
|
+
request = Net::HTTP::Post.new(uri.request_uri, { "content-type" => "application/json" })
|
|
26
|
+
request.body = body
|
|
27
|
+
http.request(request)
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_body(api_key, events)
|
|
35
|
+
{ apiKey: api_key, events: events.map { |event| to_wire_format(event) } }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Converts an internal (snake_case) event hash to the fixed camelCase
|
|
39
|
+
# wire shape (docs/snare-multi-language-sdk-wire-protocol.md, Section 1).
|
|
40
|
+
def to_wire_format(event)
|
|
41
|
+
wire = {
|
|
42
|
+
message: event[:message],
|
|
43
|
+
timestamp: event[:timestamp],
|
|
44
|
+
consoleLog: event[:console_log],
|
|
45
|
+
platform: event[:platform],
|
|
46
|
+
}
|
|
47
|
+
wire[:stack] = event[:stack] if event.key?(:stack)
|
|
48
|
+
wire
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/snare.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "snare/capture"
|
|
4
|
+
require "snare/queue"
|
|
5
|
+
require "snare/transport"
|
|
6
|
+
|
|
7
|
+
# Snare error-monitoring SDK. See README.md for usage.
|
|
8
|
+
module Snare
|
|
9
|
+
DEFAULT_API_BASE = "https://intake.snare.dev"
|
|
10
|
+
FLUSH_SIZE = 5
|
|
11
|
+
FLUSH_INTERVAL_SECONDS = 10
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Registers the at_exit crash-capture handler and starts the batching
|
|
15
|
+
# queue. Keyword arguments, idiomatic Ruby. Safe to call more than once —
|
|
16
|
+
# each call re-initializes cleanly, discarding anything queued under the
|
|
17
|
+
# previous configuration rather than flushing it to stale settings.
|
|
18
|
+
def init(project_id:, api_key:, api_base: DEFAULT_API_BASE)
|
|
19
|
+
@project_id = project_id
|
|
20
|
+
@api_key = api_key
|
|
21
|
+
@api_base = api_base
|
|
22
|
+
|
|
23
|
+
@queue&.drain
|
|
24
|
+
|
|
25
|
+
@queue = Snare::Queue.new(flush_size: FLUSH_SIZE, flush_interval: FLUSH_INTERVAL_SECONDS) do |events|
|
|
26
|
+
Snare::Transport.send_batch(api_base: @api_base, project_id: @project_id, api_key: @api_key, events: events)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
register_at_exit_hook
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# For the host app's own rescue blocks. `exception` defaults to `$!` (the
|
|
34
|
+
# exception actively propagating in a live rescue block) so this can also
|
|
35
|
+
# be called bare from inside one — a nice-to-have since Ruby has no
|
|
36
|
+
# equivalent of Python's ambient sys.exc_info() idiom.
|
|
37
|
+
def capture_exception(exception = $!)
|
|
38
|
+
unless @queue
|
|
39
|
+
warn "[snare-sdk] Snare.capture_exception called before Snare.init"
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
unless exception
|
|
43
|
+
warn "[snare-sdk] Snare.capture_exception called with no exception and no active $!"
|
|
44
|
+
return nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@queue.enqueue(Snare::Capture.build_captured_event(exception: exception, now: (Time.now.to_f * 1000).to_i))
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Forces immediate delivery of whatever is queued. Used by the at_exit
|
|
52
|
+
# hook (at_exit runs right before process death — a background timer
|
|
53
|
+
# can't be relied on to fire after that point) and available for a host
|
|
54
|
+
# app to call explicitly, e.g. in a graceful-shutdown handler.
|
|
55
|
+
def flush
|
|
56
|
+
@queue&.flush
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Ruby has no global hook as clean as Python's sys.excepthook. `at_exit`
|
|
63
|
+
# checking `$!` (set during an abnormal exit) is the standard approach —
|
|
64
|
+
# the same one Sentry's own Ruby SDK uses. This only catches exceptions
|
|
65
|
+
# that propagate to the top of the MAIN thread: Ruby raises an exception
|
|
66
|
+
# that kills a background Thread silently by default (unless
|
|
67
|
+
# Thread.report_on_exception is set), with no clean generic hook to
|
|
68
|
+
# intercept it — an explicitly documented gap for v1, see README.md.
|
|
69
|
+
def register_at_exit_hook
|
|
70
|
+
return if @at_exit_registered
|
|
71
|
+
|
|
72
|
+
@at_exit_registered = true
|
|
73
|
+
at_exit do
|
|
74
|
+
if $!
|
|
75
|
+
capture_exception($!)
|
|
76
|
+
flush
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: snare-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Snare
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Captures unhandled exceptions and manually-reported errors and reports
|
|
13
|
+
them to Snare (https://snare.dev).
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- README.md
|
|
19
|
+
- lib/snare.rb
|
|
20
|
+
- lib/snare/capture.rb
|
|
21
|
+
- lib/snare/queue.rb
|
|
22
|
+
- lib/snare/transport.rb
|
|
23
|
+
licenses: []
|
|
24
|
+
metadata: {}
|
|
25
|
+
rdoc_options: []
|
|
26
|
+
require_paths:
|
|
27
|
+
- lib
|
|
28
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
34
|
+
requirements:
|
|
35
|
+
- - ">="
|
|
36
|
+
- !ruby/object:Gem::Version
|
|
37
|
+
version: '0'
|
|
38
|
+
requirements: []
|
|
39
|
+
rubygems_version: 4.0.16
|
|
40
|
+
specification_version: 4
|
|
41
|
+
summary: Snare error-monitoring SDK for Ruby
|
|
42
|
+
test_files: []
|