tinymonrb 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: c33d3e807a92d0f19e975e8d384e2a6a9e78ed22e62c4f2fdf5a5c2e3ff953c7
4
+ data.tar.gz: 667a99ab8a3b6d3e08780c56e99ccbadbf0c748cd3c2524dfd8ecc78e8911b6b
5
+ SHA512:
6
+ metadata.gz: '085fdbe62bc6c876ee0c15d40de22f7645141064d7f12cd9035789d2684bfcf3f19f7c1eeb0e0a830311df6eaaa219d3018997b0a0fb96576147a63fe0cd455d'
7
+ data.tar.gz: 13543ba9734ac10fc3a9ccda8b9c456d7d39e57314449f7583ab4dc48572c9d40051d0bd8f4cfe62d3660f74b23b3dcef5834fac20a464c709685eefd2f9af78
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event_builder"
4
+ require_relative "scope"
5
+ require_relative "transport"
6
+
7
+ module Tinymon
8
+ class Client
9
+ DEFAULT_ENDPOINT = "https://console.tinymon.dev/api/ingest"
10
+
11
+ def initialize(dsn:, endpoint: nil, environment: nil, release: nil, sample_rate: 1.0, before_send: nil)
12
+ @dsn = dsn
13
+ @endpoint = endpoint || DEFAULT_ENDPOINT
14
+ @environment = environment
15
+ @release = release
16
+ @sample_rate = sample_rate
17
+ @before_send = before_send
18
+ @transport = Transport.new(@endpoint, dsn)
19
+ end
20
+
21
+ def capture_exception(exception)
22
+ return if rand > @sample_rate
23
+ snap = SCOPE.snapshot
24
+ event = EventBuilder.build(
25
+ exception,
26
+ release: @release,
27
+ environment: @environment,
28
+ user: snap[:user],
29
+ tags: snap[:tags],
30
+ breadcrumbs: snap[:breadcrumbs],
31
+ )
32
+ if @before_send
33
+ event = @before_send.call(event)
34
+ return if event.nil?
35
+ end
36
+ @transport.enqueue(event)
37
+ rescue StandardError
38
+ # SWALLOW. The SDK must never throw into the host app.
39
+ nil
40
+ end
41
+
42
+ def capture_message(message, level: "info")
43
+ synthetic = StandardError.new(message)
44
+ snap = SCOPE.snapshot
45
+ event = EventBuilder.build(
46
+ synthetic,
47
+ release: @release,
48
+ environment: @environment,
49
+ user: snap[:user],
50
+ tags: snap[:tags],
51
+ breadcrumbs: snap[:breadcrumbs],
52
+ )
53
+ event["level"] = level
54
+ event["exception"]["type"] = "Message"
55
+ @transport.enqueue(event)
56
+ rescue StandardError
57
+ nil
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "version"
5
+ require_relative "stacktrace"
6
+
7
+ module Tinymon
8
+ # Pure construction of a wire-format event from a Ruby Exception.
9
+ # Mirrors packages/browser/src/eventBuilder.ts and packages/python/.../event_builder.py.
10
+ module EventBuilder
11
+ module_function
12
+
13
+ SENSITIVE = /password|token|secret|auth|card|cvv|ssn/i.freeze
14
+
15
+ def build(exception, release: nil, environment: nil, user: nil, tags: nil, breadcrumbs: nil, url: nil)
16
+ event = {
17
+ "event_id" => SecureRandom.uuid,
18
+ "timestamp" => Time.now.to_f,
19
+ "platform" => "ruby",
20
+ "level" => "error",
21
+ "sdk" => { "name" => SDK_NAME, "version" => VERSION },
22
+ "exception" => {
23
+ "type" => exception.class.name.to_s,
24
+ "value" => exception.message.to_s,
25
+ "stacktrace" => { "frames" => Stacktrace.parse(exception) },
26
+ },
27
+ "breadcrumbs" => (breadcrumbs || []).dup,
28
+ "user" => (user || {}).dup,
29
+ "tags" => scrub(tags || {}),
30
+ }
31
+ event["release"] = release if release
32
+ event["environment"] = environment if environment
33
+ event["request"] = { "url" => url } if url
34
+ event
35
+ end
36
+
37
+ def scrub(tags)
38
+ tags.each_with_object({}) do |(k, v), out|
39
+ out[k.to_s] = SENSITIVE.match?(k.to_s) ? "[redacted]" : v
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tinymon
4
+ # Capture unhandled exceptions at process exit. Ruby doesn't have a true
5
+ # global "uncaught exception" hook the way Python (sys.excepthook) or Java
6
+ # (Thread.setDefaultUncaughtExceptionHandler) do — the closest equivalent is
7
+ # checking $! inside an at_exit block, which fires after a fatal exception
8
+ # has propagated out of `main`.
9
+ module Integrations
10
+ module_function
11
+
12
+ def install(client)
13
+ at_exit do
14
+ exc = $! # rubocop:disable Style/SpecialGlobalVars
15
+ # SystemExit is normal program exit; ignore it.
16
+ if exc && !exc.is_a?(SystemExit)
17
+ begin
18
+ client.capture_exception(exc)
19
+ rescue StandardError
20
+ # swallow
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tinymon
4
+ # Process-wide user/tags/breadcrumbs that ride along with every event.
5
+ class Scope
6
+ MAX_BREADCRUMBS = 50
7
+
8
+ attr_reader :user, :tags, :breadcrumbs
9
+
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @user = {}
13
+ @tags = {}
14
+ @breadcrumbs = []
15
+ end
16
+
17
+ def set_user(user)
18
+ @mutex.synchronize { @user = user.dup }
19
+ end
20
+
21
+ def set_tag(key, value)
22
+ @mutex.synchronize { @tags[key.to_s] = value }
23
+ end
24
+
25
+ def add_breadcrumb(crumb)
26
+ @mutex.synchronize do
27
+ @breadcrumbs.push(crumb)
28
+ @breadcrumbs.shift if @breadcrumbs.length > MAX_BREADCRUMBS
29
+ end
30
+ end
31
+
32
+ def snapshot
33
+ @mutex.synchronize do
34
+ {
35
+ user: @user.dup,
36
+ tags: @tags.dup,
37
+ breadcrumbs: @breadcrumbs.dup,
38
+ }
39
+ end
40
+ end
41
+ end
42
+
43
+ # Singleton instance — mirrors the JS/Python SDKs' module-level scope.
44
+ SCOPE = Scope.new
45
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Tinymon
6
+ # Convert a Ruby Exception's backtrace into wire-format stack frames.
7
+ #
8
+ # Wire format wants frames ordered deepest-LAST. Ruby's #backtrace returns
9
+ # them deepest-FIRST (raise site first, then callers), so we reverse.
10
+ module Stacktrace
11
+ module_function
12
+
13
+ # Falsy heuristic: a frame is NOT in_app if its path lives inside a gem
14
+ # directory, the Ruby stdlib, or is a built-in (`<internal:...>`).
15
+ GEM_MARKER = "/gems/"
16
+ INTERNAL_PREFIX = "<"
17
+ STDLIB_PATH = RbConfig::CONFIG["rubylibdir"].to_s
18
+
19
+ def parse(exception)
20
+ return [] unless exception
21
+
22
+ locations = nil
23
+ begin
24
+ locations = exception.backtrace_locations
25
+ rescue StandardError
26
+ locations = nil
27
+ end
28
+
29
+ if locations && !locations.empty?
30
+ frames = locations.map { |loc| from_location(loc) }
31
+ else
32
+ bt = exception.backtrace || []
33
+ frames = bt.map { |line| parse_string(line) }.compact
34
+ end
35
+
36
+ # Ruby gives deepest-first; wire format wants deepest-LAST.
37
+ frames.reverse
38
+ end
39
+
40
+ def from_location(loc)
41
+ path = loc.path.to_s
42
+ {
43
+ "filename" => path,
44
+ "function" => (loc.base_label || loc.label || "<anonymous>").to_s,
45
+ "lineno" => loc.lineno.to_i,
46
+ "colno" => 0,
47
+ "in_app" => in_app?(path),
48
+ }
49
+ end
50
+
51
+ # Match strings like:
52
+ # "path/to/file.rb:123:in `method_name'"
53
+ # "path/to/file.rb:123:in 'method_name'"
54
+ BT_LINE = /\A(.+?):(\d+)(?::in [`'](.+?)')?\z/.freeze
55
+
56
+ def parse_string(line)
57
+ m = BT_LINE.match(line.to_s)
58
+ return nil unless m
59
+ filename = m[1]
60
+ {
61
+ "filename" => filename,
62
+ "function" => (m[3] || "<anonymous>"),
63
+ "lineno" => m[2].to_i,
64
+ "colno" => 0,
65
+ "in_app" => in_app?(filename),
66
+ }
67
+ end
68
+
69
+ def in_app?(path)
70
+ return false if path.nil? || path.empty?
71
+ return false if path.start_with?(INTERNAL_PREFIX)
72
+ return false if path.include?(GEM_MARKER)
73
+ return false if !STDLIB_PATH.empty? && path.start_with?(STDLIB_PATH)
74
+ true
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Tinymon
8
+ # Batched HTTP transport. stdlib-only. A background thread flushes every
9
+ # 5 seconds; at_exit drains the queue on shutdown.
10
+ class Transport
11
+ FLUSH_INTERVAL = 5.0 # seconds
12
+ MAX_BATCH = 10
13
+ MAX_QUEUE = 30
14
+ REQUEST_TIMEOUT = 5 # seconds
15
+
16
+ def initialize(endpoint, dsn)
17
+ @endpoint = endpoint
18
+ @dsn = dsn
19
+ @queue = []
20
+ @mutex = Mutex.new
21
+ @stop = false
22
+ @thread = Thread.new { run_loop }
23
+ @thread.name = "tinymon-transport" if @thread.respond_to?(:name=)
24
+ at_exit { shutdown }
25
+ end
26
+
27
+ def enqueue(event)
28
+ should_flush = false
29
+ @mutex.synchronize do
30
+ @queue.shift while @queue.length >= MAX_QUEUE
31
+ @queue.push(event)
32
+ should_flush = @queue.length >= MAX_BATCH
33
+ end
34
+ flush if should_flush
35
+ end
36
+
37
+ def flush
38
+ batch = nil
39
+ @mutex.synchronize do
40
+ batch = @queue.shift(MAX_BATCH)
41
+ end
42
+ return if batch.nil? || batch.empty?
43
+ batch.each { |event| send_one(event) }
44
+ end
45
+
46
+ private
47
+
48
+ def send_one(event)
49
+ uri = URI.parse(@endpoint)
50
+ http = Net::HTTP.new(uri.host, uri.port)
51
+ http.use_ssl = (uri.scheme == "https")
52
+ http.open_timeout = REQUEST_TIMEOUT
53
+ http.read_timeout = REQUEST_TIMEOUT
54
+ req = Net::HTTP::Post.new(uri.request_uri, {
55
+ "Content-Type" => "application/json",
56
+ "X-Tinymon-Key" => @dsn,
57
+ })
58
+ req.body = JSON.dump(event)
59
+ begin
60
+ http.request(req)
61
+ rescue StandardError
62
+ # Network failed — re-enqueue if there's room.
63
+ @mutex.synchronize do
64
+ @queue.unshift(event) if @queue.length < MAX_QUEUE
65
+ end
66
+ end
67
+ end
68
+
69
+ def run_loop
70
+ until @stop
71
+ sleep(FLUSH_INTERVAL)
72
+ begin
73
+ flush
74
+ rescue StandardError
75
+ # swallow — never crash the host app
76
+ end
77
+ end
78
+ end
79
+
80
+ def shutdown
81
+ @stop = true
82
+ begin
83
+ flush
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tinymon
4
+ VERSION = "0.1.0"
5
+ SDK_NAME = "tinymon.ruby"
6
+ end
data/lib/tinymon.rb ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tinymon/version"
4
+ require_relative "tinymon/stacktrace"
5
+ require_relative "tinymon/event_builder"
6
+ require_relative "tinymon/scope"
7
+ require_relative "tinymon/transport"
8
+ require_relative "tinymon/client"
9
+ require_relative "tinymon/integrations"
10
+
11
+ # Public API:
12
+ #
13
+ # require "tinymon"
14
+ # Tinymon.init(dsn: "tm_pub_xxx", environment: "production", release: "1.0.0")
15
+ #
16
+ # begin
17
+ # risky_thing
18
+ # rescue => e
19
+ # Tinymon.capture_exception(e)
20
+ # end
21
+ #
22
+ module Tinymon
23
+ @client = nil
24
+
25
+ class << self
26
+ def init(dsn:, endpoint: nil, environment: nil, release: nil, sample_rate: 1.0, before_send: nil)
27
+ @client = Client.new(
28
+ dsn: dsn,
29
+ endpoint: endpoint,
30
+ environment: environment,
31
+ release: release,
32
+ sample_rate: sample_rate,
33
+ before_send: before_send,
34
+ )
35
+ Integrations.install(@client)
36
+ @client
37
+ end
38
+
39
+ def capture_exception(exception)
40
+ @client&.capture_exception(exception)
41
+ end
42
+
43
+ def capture_message(message, level: "info")
44
+ @client&.capture_message(message, level: level)
45
+ end
46
+
47
+ def set_user(user)
48
+ SCOPE.set_user(user)
49
+ end
50
+
51
+ def set_tag(key, value)
52
+ SCOPE.set_tag(key, value)
53
+ end
54
+
55
+ def add_breadcrumb(crumb)
56
+ SCOPE.add_breadcrumb(crumb)
57
+ end
58
+
59
+ # Exposed for the cross-language contract test in test/test_contract.rb.
60
+ def _build_event(exception, **kwargs)
61
+ EventBuilder.build(exception, **kwargs)
62
+ end
63
+ end
64
+ end
data/lib/tinymonrb.rb ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tinymon/version"
4
+ require_relative "tinymon/stacktrace"
5
+ require_relative "tinymon/event_builder"
6
+ require_relative "tinymon/scope"
7
+ require_relative "tinymon/transport"
8
+ require_relative "tinymon/client"
9
+ require_relative "tinymon/integrations"
10
+
11
+ # Public API:
12
+ #
13
+ # require "tinymon"
14
+ # Tinymon.init(dsn: "tm_pub_xxx", environment: "production", release: "1.0.0")
15
+ #
16
+ # begin
17
+ # risky_thing
18
+ # rescue => e
19
+ # Tinymon.capture_exception(e)
20
+ # end
21
+ #
22
+ module Tinymon
23
+ @client = nil
24
+
25
+ class << self
26
+ def init(dsn:, endpoint: nil, environment: nil, release: nil, sample_rate: 1.0, before_send: nil)
27
+ @client = Client.new(
28
+ dsn: dsn,
29
+ endpoint: endpoint,
30
+ environment: environment,
31
+ release: release,
32
+ sample_rate: sample_rate,
33
+ before_send: before_send,
34
+ )
35
+ Integrations.install(@client)
36
+ @client
37
+ end
38
+
39
+ def capture_exception(exception)
40
+ @client&.capture_exception(exception)
41
+ end
42
+
43
+ def capture_message(message, level: "info")
44
+ @client&.capture_message(message, level: level)
45
+ end
46
+
47
+ def set_user(user)
48
+ SCOPE.set_user(user)
49
+ end
50
+
51
+ def set_tag(key, value)
52
+ SCOPE.set_tag(key, value)
53
+ end
54
+
55
+ def add_breadcrumb(crumb)
56
+ SCOPE.add_breadcrumb(crumb)
57
+ end
58
+
59
+ # Exposed for the cross-language contract test in test/test_contract.rb.
60
+ def _build_event(exception, **kwargs)
61
+ EventBuilder.build(exception, **kwargs)
62
+ end
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tinymonrb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tinymon
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json_schemer
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ description: Captures unhandled exceptions and ships them to a tinymon ingest endpoint.
42
+ email:
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/tinymon.rb
48
+ - lib/tinymon/client.rb
49
+ - lib/tinymon/event_builder.rb
50
+ - lib/tinymon/integrations.rb
51
+ - lib/tinymon/scope.rb
52
+ - lib/tinymon/stacktrace.rb
53
+ - lib/tinymon/transport.rb
54
+ - lib/tinymon/version.rb
55
+ - lib/tinymonrb.rb
56
+ homepage: https://tinymon.dev
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://tinymon.dev
61
+ documentation_uri: https://tinymon.dev/docs/ruby.html
62
+ source_code_uri: https://github.com/tinymon/tinymon
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '2.7'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.0.3.1
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Tiny error monitoring SDK for Ruby.
82
+ test_files: []