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 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
@@ -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: []