tinymonrb 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fb61a62ef1e259893c449ab46dbbbe6ba67f300a45551cb093e4598adeac193
4
- data.tar.gz: 12f762661bba46acbbce593f7558e5dbc38ed1a2b181c9ed14e334bacc47a3de
3
+ metadata.gz: 721bdc7f477b8281a78914201aadf4367fe02206248735b91dc6c564459c4050
4
+ data.tar.gz: 5b119d9cd4b65d8cef7648b779be6a84f93ffca1c78c7fd0560e63529ca31560
5
5
  SHA512:
6
- metadata.gz: 53ef44902a951ef0f01c463f94d8ebe841b35718b9a40ffe071e8ed559e34c939a6012e05e7a7b78aab0fcda7704a35aed814ffd982283a22a61633335ff97e4
7
- data.tar.gz: cf09570386509420ff6952856d6366aa15d304699fc6b8d4692a877acb74f59d49933ca4d5d5154a7db24632c857f09272828324ad8dae40c8fb893b2f2ed9e7
6
+ metadata.gz: 911fac08481f040b354966c3baf7d14db96d9a5deab26b86bd077b432c430eca3dcda7c9dc84e0fc600efd344c4c8354c31137651e3b22cada233e976b142a47
7
+ data.tar.gz: b3cf2caffb694ba16700b9f69d758d2f025d87bae3a4c1849bc10d0899118905a096082b79a29aa87f23b786b69ed48f82fb7231c419c3b2d02bf40e8ef032a2
@@ -37,7 +37,7 @@ module Tinymon
37
37
  return if rand > @sample_rate
38
38
  event = _prepare(exception)
39
39
  return if event.nil?
40
- @transport.enqueue(event)
40
+ @transport.send_event(event)
41
41
  rescue StandardError
42
42
  # SWALLOW. The SDK must never throw into the host app.
43
43
  nil
@@ -48,22 +48,44 @@ module Tinymon
48
48
  return if event.nil?
49
49
  event["level"] = level
50
50
  event["exception"]["type"] = "Message"
51
- @transport.enqueue(event)
51
+ @transport.send_event(event)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ # Per-event context capture. Use from Rack middleware; do NOT use
57
+ # Tinymon.set_user/set_tag for per-request data — they race under
58
+ # concurrency.
59
+ def capture_exception_with_context(exception, request: nil, user: nil, tags: nil)
60
+ return if rand > @sample_rate
61
+ event = _prepare(exception, ctx: { request: request, user: user, tags: tags })
62
+ return if event.nil?
63
+ @transport.send_event(event)
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ # Block until queued events are delivered (or timeout). Call before a
69
+ # short-lived process exits so events aren't dropped.
70
+ def flush(timeout = 2.0)
71
+ @transport.flush(timeout)
52
72
  rescue StandardError
53
73
  nil
54
74
  end
55
75
 
56
76
  private
57
77
 
58
- def _prepare(exception)
78
+ def _prepare(exception, ctx: nil)
59
79
  snap = SCOPE.snapshot
80
+ ctx ||= {}
60
81
  event = EventBuilder.build(
61
82
  exception,
62
83
  release: @release,
63
84
  environment: @environment,
64
- user: snap[:user],
65
- tags: snap[:tags],
85
+ user: ctx[:user] || snap[:user],
86
+ tags: ctx[:tags] || snap[:tags],
66
87
  breadcrumbs: snap[:breadcrumbs],
88
+ request: ctx[:request],
67
89
  )
68
90
  # Default scrub BEFORE before_send.
69
91
  Scrub.scrub_event(event, scrub_url_query: @scrub_url_query, scrub_patterns: @scrub_patterns)
@@ -12,7 +12,7 @@ module Tinymon
12
12
 
13
13
  SENSITIVE = /password|token|secret|auth|card|cvv|ssn/i.freeze
14
14
 
15
- def build(exception, release: nil, environment: nil, user: nil, tags: nil, breadcrumbs: nil, url: nil)
15
+ def build(exception, release: nil, environment: nil, user: nil, tags: nil, breadcrumbs: nil, url: nil, request: nil)
16
16
  event = {
17
17
  "event_id" => SecureRandom.uuid,
18
18
  "timestamp" => Time.now.to_f,
@@ -30,7 +30,16 @@ module Tinymon
30
30
  }
31
31
  event["release"] = release if release
32
32
  event["environment"] = environment if environment
33
- event["request"] = { "url" => url } if url
33
+ if request && (request[:method] || request["method"] || request[:url] || request["url"])
34
+ req = {}
35
+ m = request[:method] || request["method"]
36
+ u = request[:url] || request["url"]
37
+ req["method"] = m if m
38
+ req["url"] = u if u
39
+ event["request"] = req
40
+ elsif url
41
+ event["request"] = { "url" => url }
42
+ end
34
43
  event
35
44
  end
36
45
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack middleware. Insert into ANY Rack app (Rails, Sinatra, Hanami,
4
+ # plain Rack) to capture in-request exceptions. Captures with per-event
5
+ # request context (method + URL) and RE-RAISES so the framework's own
6
+ # error handling still fires.
7
+ #
8
+ # Manual insert:
9
+ # require "tinymonrb"
10
+ # use Tinymon::Rack # Sinatra / plain Rack
11
+ # # Rails:
12
+ # config.middleware.use Tinymon::Rack
13
+ #
14
+ # Or use the Railtie for auto-insert in Rails apps (loaded automatically
15
+ # when Rails is detected at runtime).
16
+
17
+ module Tinymon
18
+ class Rack
19
+ def initialize(app)
20
+ @app = app
21
+ end
22
+
23
+ def call(env)
24
+ @app.call(env)
25
+ rescue StandardError, ScriptError => e
26
+ capture(e, env)
27
+ raise
28
+ end
29
+
30
+ private
31
+
32
+ def capture(exc, env)
33
+ mod = ::Tinymon
34
+ client = mod.instance_variable_get(:@client)
35
+ return unless client
36
+
37
+ url = build_url(env)
38
+ method = env["REQUEST_METHOD"]
39
+ client.capture_exception_with_context(
40
+ exc,
41
+ request: { method: method, url: url }
42
+ )
43
+ rescue StandardError
44
+ # never let the SDK break the request
45
+ nil
46
+ end
47
+
48
+ def build_url(env)
49
+ scheme = env["rack.url_scheme"] || "http"
50
+ host = env["HTTP_HOST"] || env["SERVER_NAME"] || ""
51
+ path = env["REQUEST_URI"] ||
52
+ "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}#{env['QUERY_STRING'] && !env['QUERY_STRING'].empty? ? "?#{env['QUERY_STRING']}" : ''}"
53
+ host.empty? ? path : "#{scheme}://#{host}#{path}"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-insert Tinymon::Rack into the Rails middleware stack. Loaded only
4
+ # when Rails is present at require time — no hard Rails dependency.
5
+ require_relative "rack"
6
+
7
+ module Tinymon
8
+ class Railtie < ::Rails::Railtie
9
+ initializer "tinymon.middleware" do |app|
10
+ app.middleware.use Tinymon::Rack
11
+ end
12
+ end
13
+ end
@@ -5,46 +5,55 @@ require "net/http"
5
5
  require "uri"
6
6
 
7
7
  module Tinymon
8
- # Batched HTTP transport. stdlib-only. A background thread flushes every
9
- # 5 seconds; at_exit drains the queue on shutdown.
8
+ # HTTP transport. stdlib-only. Sends each event immediately on a background
9
+ # worker thread; events are only queued when the network fails, and that
10
+ # retry queue is drained with exponential backoff. This matches how
11
+ # production error SDKs behave — the error lands in the dashboard now, not
12
+ # on a 5-second timer. at_exit drains anything still pending on shutdown.
10
13
  class Transport
11
- FLUSH_INTERVAL = 5.0 # seconds
12
- MAX_BATCH = 10
13
- MAX_QUEUE = 30
14
- REQUEST_TIMEOUT = 5 # seconds
14
+ MAX_QUEUE = 30 # cap on retry buffer (dead-network memory guard)
15
+ REQUEST_TIMEOUT = 5 # seconds
16
+ BACKOFF_START = 1.0 # seconds
17
+ BACKOFF_MAX = 30.0 # seconds
15
18
 
16
19
  def initialize(endpoint, dsn)
17
20
  @endpoint = endpoint
18
21
  @dsn = dsn
19
- @queue = []
22
+ @pending = [] # fresh events — sent immediately
23
+ @retry = [] # failed events — retried with backoff
20
24
  @mutex = Mutex.new
25
+ @cond = ConditionVariable.new
26
+ @backoff = BACKOFF_START
21
27
  @stop = false
22
28
  @thread = Thread.new { run_loop }
23
29
  @thread.name = "tinymon-transport" if @thread.respond_to?(:name=)
24
30
  at_exit { shutdown }
25
31
  end
26
32
 
27
- def enqueue(event)
28
- should_flush = false
33
+ # Queue for immediate send on the worker thread (non-blocking).
34
+ def send_event(event)
29
35
  @mutex.synchronize do
30
- @queue.shift while @queue.length >= MAX_QUEUE
31
- @queue.push(event)
32
- should_flush = @queue.length >= MAX_BATCH
36
+ @pending.push(event)
37
+ @cond.signal
33
38
  end
34
- flush if should_flush
35
39
  end
36
40
 
37
- def flush
38
- batch = nil
39
- @mutex.synchronize do
40
- batch = @queue.shift(MAX_BATCH)
41
+ # Block until pending + retry events are sent, or timeout elapses.
42
+ def flush(timeout = 2.0)
43
+ deadline = monotonic + timeout
44
+ loop do
45
+ empty = @mutex.synchronize { @pending.empty? && @retry.empty? }
46
+ return if empty
47
+ break if monotonic >= deadline
48
+ @mutex.synchronize { @cond.signal }
49
+ sleep(0.02)
41
50
  end
42
- return if batch.nil? || batch.empty?
43
- batch.each { |event| send_one(event) }
44
51
  end
45
52
 
46
53
  private
47
54
 
55
+ # Returns true on success OR an unrecoverable 4xx (retrying a client bug
56
+ # won't help); false on 5xx / network failure.
48
57
  def send_one(event)
49
58
  uri = URI.parse(@endpoint)
50
59
  http = Net::HTTP.new(uri.host, uri.port)
@@ -57,33 +66,89 @@ module Tinymon
57
66
  })
58
67
  req.body = JSON.dump(event)
59
68
  begin
60
- http.request(req)
69
+ res = http.request(req)
70
+ code = res.code.to_i
71
+ return true if code >= 200 && code < 300
72
+ return true if code >= 400 && code < 500 # client bug — drop, don't retry
73
+ false
61
74
  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
75
+ false
66
76
  end
67
77
  end
68
78
 
69
79
  def run_loop
70
80
  until @stop
71
- sleep(FLUSH_INTERVAL)
72
- begin
73
- flush
74
- rescue StandardError
75
- # swallow — never crash the host app
81
+ event = next_pending
82
+ if event
83
+ queue_retry(event) unless send_one(event)
84
+ next
85
+ end
86
+
87
+ next if drain_retry
88
+
89
+ # Idle — wait for new work or the next retry window.
90
+ @mutex.synchronize do
91
+ wait = @retry.empty? ? nil : @backoff
92
+ @cond.wait(@mutex, wait) unless @stop
93
+ end
94
+ end
95
+ end
96
+
97
+ def next_pending
98
+ @mutex.synchronize { @pending.shift }
99
+ end
100
+
101
+ def queue_retry(event)
102
+ @mutex.synchronize do
103
+ @retry.shift while @retry.length >= MAX_QUEUE
104
+ @retry.push(event)
105
+ end
106
+ end
107
+
108
+ def drain_retry
109
+ batch = @mutex.synchronize do
110
+ next nil if @retry.empty?
111
+ b = @retry.dup
112
+ @retry.clear
113
+ b
114
+ end
115
+ return false if batch.nil?
116
+
117
+ any_failed = false
118
+ batch.each do |event|
119
+ unless send_one(event)
120
+ any_failed = true
121
+ queue_retry(event)
76
122
  end
77
123
  end
124
+ if any_failed
125
+ @backoff = [@backoff * 2, BACKOFF_MAX].min
126
+ sleep(@backoff)
127
+ else
128
+ @backoff = BACKOFF_START
129
+ end
130
+ true
78
131
  end
79
132
 
80
133
  def shutdown
81
- @stop = true
82
- begin
83
- flush
84
- rescue StandardError
85
- nil
134
+ # Best-effort: deliver anything still pending before exit.
135
+ deadline = monotonic + 2.0
136
+ while monotonic < deadline
137
+ event = next_pending
138
+ event ||= @mutex.synchronize { @retry.shift }
139
+ break if event.nil?
140
+ send_one(event)
141
+ end
142
+ @mutex.synchronize do
143
+ @stop = true
144
+ @cond.broadcast
86
145
  end
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
150
+ def monotonic
151
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
152
  end
88
153
  end
89
154
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tinymon
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  SDK_NAME = "tinymon.ruby"
6
6
  end
data/lib/tinymon.rb CHANGED
@@ -1,64 +1,7 @@
1
1
  # frozen_string_literal: true
2
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
3
+ # Back-compat shim. The gem is published as `tinymonrb`, so the canonical
4
+ # entry point is `require "tinymonrb"`. This file only exists so that older
5
+ # code doing `require "tinymon"` keeps working — it loads the full SDK
6
+ # (context capture, Rack middleware, Rails railtie) rather than a partial copy.
7
+ require_relative "tinymonrb"
data/lib/tinymonrb.rb CHANGED
@@ -7,6 +7,9 @@ require_relative "tinymon/scope"
7
7
  require_relative "tinymon/transport"
8
8
  require_relative "tinymon/client"
9
9
  require_relative "tinymon/integrations"
10
+ require_relative "tinymon/rack"
11
+ # Rails auto-insert — only loaded when Rails is present at require time.
12
+ require_relative "tinymon/railtie" if defined?(::Rails::Railtie)
10
13
 
11
14
  # Public API:
12
15
  #
@@ -53,10 +56,22 @@ module Tinymon
53
56
  @client&.capture_exception(exception)
54
57
  end
55
58
 
59
+ # Per-event context capture. Use from Rack middleware; do NOT use
60
+ # set_user/set_tag for per-request data (they race under concurrency).
61
+ def capture_exception_with_context(exception, request: nil, user: nil, tags: nil)
62
+ @client&.capture_exception_with_context(exception, request: request, user: user, tags: tags)
63
+ end
64
+
56
65
  def capture_message(message, level: "info")
57
66
  @client&.capture_message(message, level: level)
58
67
  end
59
68
 
69
+ # Block until queued events are delivered (or timeout). Call before a
70
+ # short-lived process exits so events aren't dropped.
71
+ def flush(timeout = 2.0)
72
+ @client&.flush(timeout)
73
+ end
74
+
60
75
  def set_user(user)
61
76
  SCOPE.set_user(user)
62
77
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tinymonrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tinymon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-09 00:00:00.000000000 Z
11
+ date: 2026-06-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json_schemer
@@ -48,6 +48,8 @@ files:
48
48
  - lib/tinymon/client.rb
49
49
  - lib/tinymon/event_builder.rb
50
50
  - lib/tinymon/integrations.rb
51
+ - lib/tinymon/rack.rb
52
+ - lib/tinymon/railtie.rb
51
53
  - lib/tinymon/scope.rb
52
54
  - lib/tinymon/scrub.rb
53
55
  - lib/tinymon/stacktrace.rb