allstak 0.1.1 → 0.2.1

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.
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "securerandom"
7
+
8
+ module AllStak
9
+ module Transport
10
+ # Filesystem spool for un-sent telemetry envelopes (offline / outage /
11
+ # shutdown durability). One JSON file per envelope so a partially written
12
+ # file can be discarded without losing the rest of the queue.
13
+ #
14
+ # Wire-on-disk shape (already-scrubbed payload):
15
+ # { "v" => 1, "path" => "/ingest/v1/errors", "payload" => {...},
16
+ # "ts" => 1700000000.123 }
17
+ #
18
+ # Sentry parity: this is the Ruby analogue of Sentry's offline/transport
19
+ # cache dir — events that cannot be delivered are persisted (PII-scrubbed)
20
+ # and replayed on the next init.
21
+ #
22
+ # HARD invariants:
23
+ # * Fail-open everywhere. A read-only FS, a sandboxed/serverless runtime,
24
+ # or any IO error degrades silently to in-memory behavior — never
25
+ # raises, never blocks capture or init.
26
+ # * Bounded by COUNT, BYTES, and AGE. When over a limit the OLDEST entry
27
+ # is dropped first (files are time-ordered by an embedded counter).
28
+ # * Caller scrubs BEFORE persist — the spool stores bytes verbatim and
29
+ # does no scrubbing itself.
30
+ class EventSpool
31
+ FILE_PREFIX = "allstak-evt-"
32
+ FILE_SUFFIX = ".json"
33
+ FORMAT_VERSION = 1
34
+
35
+ # Sane server defaults: a few MB / ~100 envelopes / 48h.
36
+ DEFAULT_MAX_ENTRIES = 100
37
+ DEFAULT_MAX_BYTES = 4 * 1024 * 1024 # 4 MiB
38
+ DEFAULT_MAX_AGE_S = 48 * 60 * 60 # 48 hours
39
+
40
+ attr_reader :dir, :available
41
+
42
+ def initialize(dir: nil, max_entries: DEFAULT_MAX_ENTRIES,
43
+ max_bytes: DEFAULT_MAX_BYTES, max_age_s: DEFAULT_MAX_AGE_S,
44
+ logger: nil)
45
+ @logger = logger
46
+ @max_entries = [max_entries.to_i, 1].max
47
+ @max_bytes = [max_bytes.to_i, 1].max
48
+ @max_age_s = max_age_s.to_i
49
+ @mutex = Mutex.new
50
+ # Monotonic-ish ordering within a process so oldest-first eviction holds
51
+ # even when two writes land in the same wall-clock millisecond.
52
+ @seq = 0
53
+ @dir = resolve_dir(dir)
54
+ @available = ensure_dir(@dir)
55
+ end
56
+
57
+ def available?
58
+ @available
59
+ end
60
+
61
+ # Persist one already-scrubbed envelope. `payload` MUST be a Ruby object
62
+ # (Hash/Array) that has ALREADY been run through the PII sanitizer by the
63
+ # caller. Fail-open: returns true on write, false on any problem.
64
+ def persist(path, payload)
65
+ return false unless @available
66
+
67
+ @mutex.synchronize do
68
+ begin
69
+ seq = (@seq += 1)
70
+ record = {
71
+ "v" => FORMAT_VERSION,
72
+ "path" => path.to_s,
73
+ "payload" => payload,
74
+ "ts" => Time.now.to_f,
75
+ "seq" => seq
76
+ }
77
+ json = JSON.generate(record)
78
+ return false if json.bytesize > @max_bytes # single oversized entry — skip
79
+
80
+ name = format("%s%013d-%06d-%s%s",
81
+ FILE_PREFIX, (Time.now.to_f * 1000).to_i, seq,
82
+ SecureRandom.hex(4), FILE_SUFFIX)
83
+ tmp = File.join(@dir, "." + name + ".tmp")
84
+ dest = File.join(@dir, name)
85
+ File.open(tmp, "wb") { |f| f.write(json) }
86
+ File.rename(tmp, dest) # atomic publish
87
+ enforce_bounds_locked
88
+ true
89
+ rescue StandardError => e
90
+ @logger&.debug("[AllStak] spool persist failed: #{e.class}: #{e.message}")
91
+ # Best-effort cleanup of a stray temp file.
92
+ begin
93
+ File.unlink(tmp) if tmp && File.exist?(tmp)
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ false
98
+ end
99
+ end
100
+ end
101
+
102
+ # Yield each persisted entry (oldest first) as [path, payload, handle].
103
+ # The caller decides per entry whether to {#remove}(handle) it (delivered
104
+ # or permanently undeliverable) or leave it for a future drain. Stale
105
+ # entries past max-age are dropped (and never yielded). Fail-open: yields
106
+ # nothing on any error.
107
+ def each
108
+ return unless @available
109
+ return unless block_given?
110
+
111
+ entries =
112
+ @mutex.synchronize do
113
+ drop_stale_locked
114
+ sorted_files_locked
115
+ end
116
+
117
+ entries.each do |file|
118
+ record =
119
+ begin
120
+ JSON.parse(File.read(file))
121
+ rescue StandardError => e
122
+ @logger&.debug("[AllStak] spool entry unreadable, dropping: #{e.class}: #{e.message}")
123
+ remove(file)
124
+ next
125
+ end
126
+ path = record.is_a?(Hash) ? record["path"] : nil
127
+ payload = record.is_a?(Hash) ? record["payload"] : nil
128
+ if path.to_s.empty? || payload.nil?
129
+ remove(file)
130
+ next
131
+ end
132
+ yield(path, payload, file)
133
+ end
134
+ end
135
+
136
+ # Delete one entry by its handle (the file path yielded by {#each}).
137
+ # Fail-open.
138
+ def remove(handle)
139
+ return unless @available
140
+ File.unlink(handle) if handle && File.exist?(handle)
141
+ rescue StandardError => e
142
+ @logger&.debug("[AllStak] spool remove failed: #{e.class}: #{e.message}")
143
+ end
144
+
145
+ # Current persisted entry count (best-effort; for tests/diagnostics).
146
+ def size
147
+ return 0 unless @available
148
+ @mutex.synchronize { sorted_files_locked.length }
149
+ rescue StandardError
150
+ 0
151
+ end
152
+
153
+ private
154
+
155
+ def resolve_dir(dir)
156
+ base = dir.to_s.strip
157
+ base.empty? ? File.join(Dir.tmpdir, "allstak-spool") : base
158
+ rescue StandardError
159
+ File.join(Dir.tmpdir, "allstak-spool")
160
+ end
161
+
162
+ # Create the dir if needed and confirm it is writable. Returns false (and
163
+ # disables the spool) on any failure — read-only FS, permission denied,
164
+ # serverless ephemeral-but-unwritable, etc.
165
+ def ensure_dir(path)
166
+ FileUtils.mkdir_p(path)
167
+ File.directory?(path) && File.writable?(path)
168
+ rescue StandardError => e
169
+ @logger&.debug("[AllStak] spool dir unavailable (#{path}): #{e.class}: #{e.message}")
170
+ false
171
+ end
172
+
173
+ # Files sorted oldest-first. The embedded zero-padded millis+seq prefix
174
+ # makes a plain lexical sort chronological.
175
+ def sorted_files_locked
176
+ Dir.glob(File.join(@dir, "#{FILE_PREFIX}*#{FILE_SUFFIX}")).sort
177
+ rescue StandardError
178
+ []
179
+ end
180
+
181
+ def drop_stale_locked
182
+ return if @max_age_s <= 0
183
+ cutoff = Time.now - @max_age_s
184
+ sorted_files_locked.each do |file|
185
+ begin
186
+ File.unlink(file) if File.mtime(file) < cutoff
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ end
191
+ end
192
+
193
+ # Enforce count + byte caps, dropping OLDEST first. Called under @mutex.
194
+ def enforce_bounds_locked
195
+ files = sorted_files_locked
196
+
197
+ # Count cap.
198
+ while files.length > @max_entries
199
+ victim = files.shift
200
+ safe_unlink(victim)
201
+ end
202
+
203
+ # Byte cap.
204
+ total = 0
205
+ sizes = files.map do |f|
206
+ s = begin
207
+ File.size(f)
208
+ rescue StandardError
209
+ 0
210
+ end
211
+ total += s
212
+ [f, s]
213
+ end
214
+ while total > @max_bytes && !sizes.empty?
215
+ file, s = sizes.shift
216
+ safe_unlink(file)
217
+ total -= s
218
+ end
219
+ end
220
+
221
+ def safe_unlink(file)
222
+ File.unlink(file) if file && File.exist?(file)
223
+ rescue StandardError
224
+ nil
225
+ end
226
+ end
227
+ end
228
+ end
@@ -1,6 +1,8 @@
1
1
  require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
+ require_relative "../sanitizer"
5
+ require_relative "event_spool"
4
6
 
5
7
  module AllStak
6
8
  module Transport
@@ -19,6 +21,17 @@ module AllStak
19
21
  class HttpTransport
20
22
  NON_RETRYABLE_STATUSES = [400, 401, 403, 404, 422].freeze
21
23
  BACKOFF_DELAYS = [1.0, 2.0, 4.0, 8.0].freeze
24
+ # Statuses for which we honor a server-provided Retry-After header.
25
+ RETRY_AFTER_STATUSES = [429, 503].freeze
26
+ # Upper bound on any honored Retry-After delay, in seconds.
27
+ MAX_RETRY_AFTER = 300.0
28
+
29
+ # Session lifecycle calls are best-effort LIVE-only — a replayed stale
30
+ # session would skew durations, so they are NEVER spooled to disk.
31
+ NON_PERSISTABLE_PATHS = [
32
+ "/ingest/v1/sessions/start",
33
+ "/ingest/v1/sessions/end"
34
+ ].freeze
22
35
 
23
36
  attr_reader :disabled
24
37
 
@@ -28,6 +41,71 @@ module AllStak
28
41
  @base_url = config.host
29
42
  @api_key = config.api_key
30
43
  @disabled = false
44
+ @spool = build_spool(config, logger)
45
+ end
46
+
47
+ # The offline spool, or nil when disabled / unavailable. Exposed for
48
+ # diagnostics + tests.
49
+ attr_reader :spool
50
+
51
+ # True when this path's telemetry is eligible for the persistent spool.
52
+ # Excludes session lifecycle calls (live-only) and is a no-op when the
53
+ # spool is disabled/unavailable.
54
+ def persistable?(path)
55
+ return false unless @spool&.available?
56
+ !NON_PERSISTABLE_PATHS.include?(path.to_s)
57
+ end
58
+
59
+ # Persist a payload that could not be delivered. The payload is scrubbed
60
+ # through the SAME PII sanitizer used on the wire BEFORE it touches disk —
61
+ # secrets never get persisted. Fail-open: returns false and never raises.
62
+ # Session lifecycle paths are skipped.
63
+ def persist_failed(path, payload)
64
+ return false unless persistable?(path)
65
+ scrubbed =
66
+ begin
67
+ parsed = payload.is_a?(String) ? JSON.parse(payload) : payload
68
+ AllStak::Sanitizer.scrub(parsed, **scrub_options)
69
+ rescue StandardError => e
70
+ @logger.debug("[AllStak] spool scrub failed; not persisting: #{e.class}: #{e.message}") if @config.debug
71
+ return false
72
+ end
73
+ @spool.persist(path, scrubbed)
74
+ rescue StandardError => e
75
+ @logger.debug("[AllStak] persist_failed swallowed: #{e.class}: #{e.message}") if @config.debug
76
+ false
77
+ end
78
+
79
+ # Replay persisted envelopes through the live transport. An entry is
80
+ # removed only when it is ACCEPTED (2xx) or PERMANENTLY undeliverable
81
+ # (a 4xx that is not 429) — anything else (network error, 5xx, 429) leaves
82
+ # it on disk for a future drain. Honors the existing retry/backoff and the
83
+ # 401-disable circuit breaker. Fully fail-open; never raises.
84
+ def drain_spool
85
+ return unless @spool&.available?
86
+ @spool.each do |path, payload, handle|
87
+ break if @disabled
88
+ begin
89
+ status, _ = post(path, payload)
90
+ # post() returns for 2xx and non-retryable 4xx; raises otherwise.
91
+ if status && (status < 400 || (status >= 400 && status != 429))
92
+ @spool.remove(handle)
93
+ end
94
+ rescue AllStakAuthError
95
+ # 401 disabled the SDK mid-drain: stop, keep remaining entries.
96
+ break
97
+ rescue AllStakTransportError => e
98
+ # Retries exhausted (network/5xx/429): keep the entry, stop draining
99
+ # so we don't hammer a down endpoint.
100
+ @logger.debug("[AllStak] drain stopped (still undeliverable): #{e.message}") if @config.debug
101
+ break
102
+ rescue StandardError => e
103
+ @logger.debug("[AllStak] drain entry error: #{e.class}: #{e.message}") if @config.debug
104
+ break
105
+ end
106
+ end
107
+ rescue StandardError => e
108
+ @logger.debug("[AllStak] drain_spool swallowed: #{e.class}: #{e.message}") if @config.debug
31
109
  end
32
110
 
33
111
  def disabled?
@@ -37,6 +115,8 @@ module AllStak
37
115
  def post(path, payload)
38
116
  raise AllStakAuthError, "SDK disabled" if @disabled
39
117
 
118
+ wire_payload = serialize_payload(payload)
119
+
40
120
  uri = URI.parse("#{@base_url}#{path}")
41
121
  http = Net::HTTP.new(uri.host, uri.port)
42
122
  http.use_ssl = (uri.scheme == "https")
@@ -45,16 +125,18 @@ module AllStak
45
125
 
46
126
  last_exc = nil
47
127
  last_status = 0
128
+ retry_after_delay = nil
48
129
  max_attempts = [[@config.max_retries.to_i, 1].max, 5].min
49
130
 
50
131
  (1..max_attempts).each do |attempt|
132
+ retry_after_delay = nil
51
133
  begin
52
134
  req = Net::HTTP::Post.new(uri.request_uri, {
53
135
  "Content-Type" => "application/json",
54
136
  "X-AllStak-Key" => @api_key,
55
137
  "User-Agent" => "allstak-ruby/#{AllStak::VERSION}"
56
138
  })
57
- req.body = payload.is_a?(String) ? payload : JSON.generate(payload)
139
+ req.body = wire_payload
58
140
  @logger.debug("[AllStak] POST #{path} attempt=#{attempt}") if @config.debug
59
141
 
60
142
  resp = http.request(req)
@@ -70,7 +152,13 @@ module AllStak
70
152
  return [last_status, body] if NON_RETRYABLE_STATUSES.include?(last_status)
71
153
  return [last_status, body] if last_status < 400
72
154
 
73
- # 5xxretry
155
+ # 429 (rate limited) / 503 (unavailable) honor Retry-After when present.
156
+ if RETRY_AFTER_STATUSES.include?(last_status)
157
+ parsed = parse_retry_after(resp["Retry-After"])
158
+ retry_after_delay = parsed if parsed > 0
159
+ end
160
+
161
+ # 5xx / 429 → retry
74
162
  rescue AllStakAuthError
75
163
  raise
76
164
  rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
@@ -83,15 +171,90 @@ module AllStak
83
171
  end
84
172
 
85
173
  if attempt < max_attempts
86
- delay = BACKOFF_DELAYS[[attempt - 1, BACKOFF_DELAYS.length - 1].min]
87
- delay += rand * 0.5
88
- sleep(delay)
174
+ if retry_after_delay
175
+ # Server told us how long to wait; honor it (already clamped).
176
+ sleep(retry_after_delay)
177
+ else
178
+ delay = BACKOFF_DELAYS[[attempt - 1, BACKOFF_DELAYS.length - 1].min]
179
+ delay += rand * 0.5
180
+ sleep(delay)
181
+ end
89
182
  end
90
183
  end
91
184
 
92
185
  raise AllStakTransportError,
93
186
  "All #{max_attempts} attempts failed for POST #{path}. last_status=#{last_status} last_error=#{last_exc&.message}"
94
187
  end
188
+
189
+ def serialize_payload(payload)
190
+ parsed = payload.is_a?(String) ? JSON.parse(payload) : payload
191
+ JSON.generate(AllStak::Sanitizer.scrub(parsed, **scrub_options))
192
+ rescue StandardError => san_err
193
+ @logger.warn("[AllStak] sanitizer failed; dropping payload: #{san_err.class}: #{san_err.message}")
194
+ raise AllStakTransportError, "sanitizer failed; payload dropped"
195
+ end
196
+
197
+ # Sanitizer options derived from config. Guarded with respond_to? so a
198
+ # bare/stub config (some transport unit tests) still scrubs with safe
199
+ # defaults: PII off (Sentry parity), no extra denylist.
200
+ def scrub_options
201
+ {
202
+ send_default_pii: @config.respond_to?(:send_default_pii?) ? @config.send_default_pii? : false,
203
+ extra_denylist: (@config.respond_to?(:extra_denylist) ? @config.extra_denylist : nil)
204
+ }
205
+ end
206
+
207
+ # Parse an HTTP `Retry-After` header into a non-negative delay in seconds.
208
+ #
209
+ # Supports both forms from RFC 7231 §7.1.3:
210
+ # - delta-seconds: an integer number of seconds ("120" → 120.0)
211
+ # - HTTP-date: an absolute date; returns the delta from `now`
212
+ #
213
+ # Returns 0.0 when the header is absent, blank, malformed, or resolves
214
+ # to a non-positive delay (e.g. a date in the past). The result is
215
+ # clamped to {MAX_RETRY_AFTER}. Pure and side-effect free.
216
+ def parse_retry_after(header, now = Time.now)
217
+ return 0.0 if header.nil?
218
+
219
+ value = header.to_s.strip
220
+ return 0.0 if value.empty?
221
+
222
+ seconds =
223
+ if value.match?(/\A\d+\z/)
224
+ value.to_i.to_f
225
+ else
226
+ begin
227
+ # HTTP-date (RFC 1123 / RFC 850 / asctime). httpdate raises on junk.
228
+ target = Time.httpdate(value)
229
+ target - now
230
+ rescue ArgumentError
231
+ return 0.0
232
+ end
233
+ end
234
+
235
+ return 0.0 if seconds.nil? || seconds <= 0
236
+ [seconds.to_f, MAX_RETRY_AFTER].min
237
+ end
238
+
239
+ private
240
+
241
+ # Build the offline spool from config, or nil when opted out. The spool
242
+ # itself fails open (an unwritable dir leaves it `available? == false`),
243
+ # so a non-nil-but-unavailable spool is fine — every public method guards
244
+ # on `available?`. Never raises.
245
+ def build_spool(config, logger)
246
+ return nil unless config.respond_to?(:enable_offline_queue)
247
+ return nil unless config.enable_offline_queue
248
+
249
+ opts = { dir: config.offline_queue_dir, logger: logger }
250
+ opts[:max_entries] = config.offline_queue_max_entries if config.offline_queue_max_entries
251
+ opts[:max_bytes] = config.offline_queue_max_bytes if config.offline_queue_max_bytes
252
+ opts[:max_age_s] = config.offline_queue_max_age_s if config.offline_queue_max_age_s
253
+ EventSpool.new(**opts)
254
+ rescue StandardError => e
255
+ logger&.debug("[AllStak] offline spool init failed; in-memory only: #{e.class}: #{e.message}")
256
+ nil
257
+ end
95
258
  end
96
259
  end
97
260
  end
@@ -1,3 +1,3 @@
1
1
  module AllStak
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/allstak.rb CHANGED
@@ -6,6 +6,10 @@ require_relative "allstak/config"
6
6
  require_relative "allstak/transport/http_transport"
7
7
  require_relative "allstak/transport/flush_buffer"
8
8
  require_relative "allstak/models/user_context"
9
+ require_relative "allstak/propagation"
10
+ require_relative "allstak/sampling"
11
+ require_relative "allstak/session_tracker"
12
+ require_relative "allstak/global_handler"
9
13
  require_relative "allstak/modules/errors"
10
14
  require_relative "allstak/modules/logs"
11
15
  require_relative "allstak/modules/http_monitor"
@@ -16,6 +20,11 @@ require_relative "allstak/client"
16
20
  require_relative "allstak/integrations/rack"
17
21
  require_relative "allstak/integrations/active_record"
18
22
  require_relative "allstak/integrations/net_http"
23
+ require_relative "allstak/integrations/sidekiq"
24
+ # The Rails Railtie self-installs on require when Rails is present, and is a
25
+ # guarded no-op otherwise. Loading it here means Rails apps that `require
26
+ # "allstak"` get the Rack middleware auto-wired without manual setup.
27
+ require_relative "allstak/integrations/rails"
19
28
 
20
29
  # Official AllStak Ruby SDK.
21
30
  #
@@ -30,8 +39,10 @@ require_relative "allstak/integrations/net_http"
30
39
  # c.service_name = "myapp-api"
31
40
  # end
32
41
  #
33
- # # Rack / Rails: add the middleware
42
+ # # Rails: auto-installed via Railtie — no manual wiring needed.
43
+ # # Plain Rack: add the middleware yourself
34
44
  # use AllStak::Integrations::Rack::Middleware
45
+ # # Sidekiq: server middleware auto-registered on configure.
35
46
  #
36
47
  # # Manual:
37
48
  # AllStak.capture_exception(exc)
@@ -47,15 +58,45 @@ module AllStak
47
58
  @mutex.synchronize do
48
59
  @config ||= Config.new
49
60
  yield @config if block_given?
61
+ # Resolve release now that explicit user config has been applied:
62
+ # explicit > env (set in #initialize) > local git > SDK version.
63
+ @config.finalize_release!
50
64
  @logger = Logger.new($stderr).tap do |l|
51
65
  l.level = @config.debug ? Logger::DEBUG : Logger::WARN
52
66
  l.progname = "allstak"
53
67
  end
54
68
  if @config.valid?
55
69
  @client = Client.new(@config, @logger)
70
+ register_runtime_release(@config, @logger)
71
+ # Release-health: open one session per process now that config +
72
+ # release are resolved. Idempotent + fully fail-open; self-disables
73
+ # under a unit-test runtime and when enable_auto_session_tracking is
74
+ # false.
75
+ @client.start_session
76
+ # Offline durability: replay telemetry persisted by a previous
77
+ # process/outage through the live transport (respects retry/backoff +
78
+ # circuit breaker). Async + fully fail-open; no-op when disabled.
79
+ @client.drain_offline_queue
56
80
  # Auto-wire integrations that are safe to install
57
81
  AllStak::Integrations::ActiveRecordIntegration::Subscriber.install!
58
82
  AllStak::Integrations::NetHTTP.install!
83
+ # Sidekiq + Rails self-guard: each is a graceful no-op when its host
84
+ # framework is absent, and idempotent when present.
85
+ AllStak::Integrations::Sidekiq.install!
86
+ AllStak::Integrations::Rails.install!
87
+ # Global uncaught-exception capture via at_exit. Idempotent and
88
+ # opt-out via config.install_at_exit_handler.
89
+ if @config.install_at_exit_handler
90
+ # report_on_exception makes Ruby print (and lets us reason about)
91
+ # exceptions that kill non-main threads; keep it on so background
92
+ # thread crashes are at least visible. Best-effort.
93
+ begin
94
+ Thread.report_on_exception = true if Thread.respond_to?(:report_on_exception=)
95
+ rescue StandardError
96
+ # never let observability config break startup
97
+ end
98
+ AllStak::GlobalHandler.install!(@logger)
99
+ end
59
100
  else
60
101
  @logger.warn("[AllStak] api_key not set — SDK not started")
61
102
  @client = nil
@@ -72,6 +113,32 @@ module AllStak
72
113
  @client or raise "AllStak not configured. Call AllStak.configure { |c| ... } first."
73
114
  end
74
115
 
116
+ def register_runtime_release(config, logger)
117
+ return unless config.auto_register_release
118
+ return if config.release.to_s.empty? || config.api_key.to_s.empty?
119
+ return if ENV["MT_TEST"] ||
120
+ ENV["RACK_ENV"] == "test" ||
121
+ ENV["RAILS_ENV"] == "test" ||
122
+ ENV["RUBYOPT"].to_s.include?("minitest") ||
123
+ $PROGRAM_NAME.to_s.include?("rspec")
124
+
125
+ thread = Thread.new do
126
+ begin
127
+ AllStak::Transport::HttpTransport.new(config, logger).post("/ingest/v1/releases", {
128
+ version: config.release,
129
+ environment: config.environment || "production",
130
+ commitSha: config.commit_sha,
131
+ branch: config.branch,
132
+ author: "#{config.sdk_name}/#{config.sdk_version}",
133
+ message: "Registered automatically by AllStak Ruby SDK at runtime"
134
+ })
135
+ rescue StandardError => e
136
+ logger.debug("[AllStak] runtime release registration failed: #{e.class}: #{e.message}") if logger
137
+ end
138
+ end
139
+ thread.abort_on_exception = false
140
+ end
141
+
75
142
  def capture_exception(exc, **kw)
76
143
  @client&.capture_exception(exc, **kw)
77
144
  end
@@ -87,6 +154,15 @@ module AllStak
87
154
  @client&.capture_message(message, level: level, **kw)
88
155
  end
89
156
 
157
+ # Manually capture an exception as a global, *unhandled* event
158
+ # (mechanism=at_exit, handled=false) and flush synchronously. Use this at
159
+ # the top of a worker/thread/process boundary where the framework would
160
+ # not otherwise route the exception through the Rack middleware. Safe
161
+ # no-op when the SDK is not configured.
162
+ def capture_unhandled(exc)
163
+ AllStak::GlobalHandler.capture_unhandled(exc)
164
+ end
165
+
90
166
  def set_user(**kw)
91
167
  @client&.set_user(**kw)
92
168
  end