allstak 0.1.1 → 0.3.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.
@@ -0,0 +1,227 @@
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
+ # Offline durability: an on-disk transport cache — events that cannot be
19
+ # delivered are persisted (PII-scrubbed) and replayed on the next init.
20
+ #
21
+ # HARD invariants:
22
+ # * Fail-open everywhere. A read-only FS, a sandboxed/serverless runtime,
23
+ # or any IO error degrades silently to in-memory behavior — never
24
+ # raises, never blocks capture or init.
25
+ # * Bounded by COUNT, BYTES, and AGE. When over a limit the OLDEST entry
26
+ # is dropped first (files are time-ordered by an embedded counter).
27
+ # * Caller scrubs BEFORE persist — the spool stores bytes verbatim and
28
+ # does no scrubbing itself.
29
+ class EventSpool
30
+ FILE_PREFIX = "allstak-evt-"
31
+ FILE_SUFFIX = ".json"
32
+ FORMAT_VERSION = 1
33
+
34
+ # Sane server defaults: a few MB / ~100 envelopes / 48h.
35
+ DEFAULT_MAX_ENTRIES = 100
36
+ DEFAULT_MAX_BYTES = 4 * 1024 * 1024 # 4 MiB
37
+ DEFAULT_MAX_AGE_S = 48 * 60 * 60 # 48 hours
38
+
39
+ attr_reader :dir, :available
40
+
41
+ def initialize(dir: nil, max_entries: DEFAULT_MAX_ENTRIES,
42
+ max_bytes: DEFAULT_MAX_BYTES, max_age_s: DEFAULT_MAX_AGE_S,
43
+ logger: nil)
44
+ @logger = logger
45
+ @max_entries = [max_entries.to_i, 1].max
46
+ @max_bytes = [max_bytes.to_i, 1].max
47
+ @max_age_s = max_age_s.to_i
48
+ @mutex = Mutex.new
49
+ # Monotonic-ish ordering within a process so oldest-first eviction holds
50
+ # even when two writes land in the same wall-clock millisecond.
51
+ @seq = 0
52
+ @dir = resolve_dir(dir)
53
+ @available = ensure_dir(@dir)
54
+ end
55
+
56
+ def available?
57
+ @available
58
+ end
59
+
60
+ # Persist one already-scrubbed envelope. `payload` MUST be a Ruby object
61
+ # (Hash/Array) that has ALREADY been run through the PII sanitizer by the
62
+ # caller. Fail-open: returns true on write, false on any problem.
63
+ def persist(path, payload)
64
+ return false unless @available
65
+
66
+ @mutex.synchronize do
67
+ begin
68
+ seq = (@seq += 1)
69
+ record = {
70
+ "v" => FORMAT_VERSION,
71
+ "path" => path.to_s,
72
+ "payload" => payload,
73
+ "ts" => Time.now.to_f,
74
+ "seq" => seq
75
+ }
76
+ json = JSON.generate(record)
77
+ return false if json.bytesize > @max_bytes # single oversized entry — skip
78
+
79
+ name = format("%s%013d-%06d-%s%s",
80
+ FILE_PREFIX, (Time.now.to_f * 1000).to_i, seq,
81
+ SecureRandom.hex(4), FILE_SUFFIX)
82
+ tmp = File.join(@dir, "." + name + ".tmp")
83
+ dest = File.join(@dir, name)
84
+ File.open(tmp, "wb") { |f| f.write(json) }
85
+ File.rename(tmp, dest) # atomic publish
86
+ enforce_bounds_locked
87
+ true
88
+ rescue StandardError => e
89
+ @logger&.debug("[AllStak] spool persist failed: #{e.class}: #{e.message}")
90
+ # Best-effort cleanup of a stray temp file.
91
+ begin
92
+ File.unlink(tmp) if tmp && File.exist?(tmp)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+ false
97
+ end
98
+ end
99
+ end
100
+
101
+ # Yield each persisted entry (oldest first) as [path, payload, handle].
102
+ # The caller decides per entry whether to {#remove}(handle) it (delivered
103
+ # or permanently undeliverable) or leave it for a future drain. Stale
104
+ # entries past max-age are dropped (and never yielded). Fail-open: yields
105
+ # nothing on any error.
106
+ def each
107
+ return unless @available
108
+ return unless block_given?
109
+
110
+ entries =
111
+ @mutex.synchronize do
112
+ drop_stale_locked
113
+ sorted_files_locked
114
+ end
115
+
116
+ entries.each do |file|
117
+ record =
118
+ begin
119
+ JSON.parse(File.read(file))
120
+ rescue StandardError => e
121
+ @logger&.debug("[AllStak] spool entry unreadable, dropping: #{e.class}: #{e.message}")
122
+ remove(file)
123
+ next
124
+ end
125
+ path = record.is_a?(Hash) ? record["path"] : nil
126
+ payload = record.is_a?(Hash) ? record["payload"] : nil
127
+ if path.to_s.empty? || payload.nil?
128
+ remove(file)
129
+ next
130
+ end
131
+ yield(path, payload, file)
132
+ end
133
+ end
134
+
135
+ # Delete one entry by its handle (the file path yielded by {#each}).
136
+ # Fail-open.
137
+ def remove(handle)
138
+ return unless @available
139
+ File.unlink(handle) if handle && File.exist?(handle)
140
+ rescue StandardError => e
141
+ @logger&.debug("[AllStak] spool remove failed: #{e.class}: #{e.message}")
142
+ end
143
+
144
+ # Current persisted entry count (best-effort; for tests/diagnostics).
145
+ def size
146
+ return 0 unless @available
147
+ @mutex.synchronize { sorted_files_locked.length }
148
+ rescue StandardError
149
+ 0
150
+ end
151
+
152
+ private
153
+
154
+ def resolve_dir(dir)
155
+ base = dir.to_s.strip
156
+ base.empty? ? File.join(Dir.tmpdir, "allstak-spool") : base
157
+ rescue StandardError
158
+ File.join(Dir.tmpdir, "allstak-spool")
159
+ end
160
+
161
+ # Create the dir if needed and confirm it is writable. Returns false (and
162
+ # disables the spool) on any failure — read-only FS, permission denied,
163
+ # serverless ephemeral-but-unwritable, etc.
164
+ def ensure_dir(path)
165
+ FileUtils.mkdir_p(path)
166
+ File.directory?(path) && File.writable?(path)
167
+ rescue StandardError => e
168
+ @logger&.debug("[AllStak] spool dir unavailable (#{path}): #{e.class}: #{e.message}")
169
+ false
170
+ end
171
+
172
+ # Files sorted oldest-first. The embedded zero-padded millis+seq prefix
173
+ # makes a plain lexical sort chronological.
174
+ def sorted_files_locked
175
+ Dir.glob(File.join(@dir, "#{FILE_PREFIX}*#{FILE_SUFFIX}")).sort
176
+ rescue StandardError
177
+ []
178
+ end
179
+
180
+ def drop_stale_locked
181
+ return if @max_age_s <= 0
182
+ cutoff = Time.now - @max_age_s
183
+ sorted_files_locked.each do |file|
184
+ begin
185
+ File.unlink(file) if File.mtime(file) < cutoff
186
+ rescue StandardError
187
+ nil
188
+ end
189
+ end
190
+ end
191
+
192
+ # Enforce count + byte caps, dropping OLDEST first. Called under @mutex.
193
+ def enforce_bounds_locked
194
+ files = sorted_files_locked
195
+
196
+ # Count cap.
197
+ while files.length > @max_entries
198
+ victim = files.shift
199
+ safe_unlink(victim)
200
+ end
201
+
202
+ # Byte cap.
203
+ total = 0
204
+ sizes = files.map do |f|
205
+ s = begin
206
+ File.size(f)
207
+ rescue StandardError
208
+ 0
209
+ end
210
+ total += s
211
+ [f, s]
212
+ end
213
+ while total > @max_bytes && !sizes.empty?
214
+ file, s = sizes.shift
215
+ safe_unlink(file)
216
+ total -= s
217
+ end
218
+ end
219
+
220
+ def safe_unlink(file)
221
+ File.unlink(file) if file && File.exist?(file)
222
+ rescue StandardError
223
+ nil
224
+ end
225
+ end
226
+ end
227
+ 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 (privacy-safe), 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.3.0"
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,15 @@ 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
+ # Optional structured-log adapter. Defined on require but NOT auto-attached —
25
+ # it only ships logs once the app opts in via Logger.attach_to_rails! /
26
+ # Logger.broadcast, so existing logging behavior is preserved by default.
27
+ require_relative "allstak/integrations/logger"
28
+ # The Rails Railtie self-installs on require when Rails is present, and is a
29
+ # guarded no-op otherwise. Loading it here means Rails apps that `require
30
+ # "allstak"` get the Rack middleware auto-wired without manual setup.
31
+ require_relative "allstak/integrations/rails"
19
32
 
20
33
  # Official AllStak Ruby SDK.
21
34
  #
@@ -30,8 +43,10 @@ require_relative "allstak/integrations/net_http"
30
43
  # c.service_name = "myapp-api"
31
44
  # end
32
45
  #
33
- # # Rack / Rails: add the middleware
46
+ # # Rails: auto-installed via Railtie — no manual wiring needed.
47
+ # # Plain Rack: add the middleware yourself
34
48
  # use AllStak::Integrations::Rack::Middleware
49
+ # # Sidekiq: server middleware auto-registered on configure.
35
50
  #
36
51
  # # Manual:
37
52
  # AllStak.capture_exception(exc)
@@ -47,15 +62,45 @@ module AllStak
47
62
  @mutex.synchronize do
48
63
  @config ||= Config.new
49
64
  yield @config if block_given?
65
+ # Resolve release now that explicit user config has been applied:
66
+ # explicit > env (set in #initialize) > local git > SDK version.
67
+ @config.finalize_release!
50
68
  @logger = Logger.new($stderr).tap do |l|
51
69
  l.level = @config.debug ? Logger::DEBUG : Logger::WARN
52
70
  l.progname = "allstak"
53
71
  end
54
72
  if @config.valid?
55
73
  @client = Client.new(@config, @logger)
74
+ register_runtime_release(@config, @logger)
75
+ # Release-health: open one session per process now that config +
76
+ # release are resolved. Idempotent + fully fail-open; self-disables
77
+ # under a unit-test runtime and when enable_auto_session_tracking is
78
+ # false.
79
+ @client.start_session
80
+ # Offline durability: replay telemetry persisted by a previous
81
+ # process/outage through the live transport (respects retry/backoff +
82
+ # circuit breaker). Async + fully fail-open; no-op when disabled.
83
+ @client.drain_offline_queue
56
84
  # Auto-wire integrations that are safe to install
57
85
  AllStak::Integrations::ActiveRecordIntegration::Subscriber.install!
58
86
  AllStak::Integrations::NetHTTP.install!
87
+ # Sidekiq + Rails self-guard: each is a graceful no-op when its host
88
+ # framework is absent, and idempotent when present.
89
+ AllStak::Integrations::Sidekiq.install!
90
+ AllStak::Integrations::Rails.install!
91
+ # Global uncaught-exception capture via at_exit. Idempotent and
92
+ # opt-out via config.install_at_exit_handler.
93
+ if @config.install_at_exit_handler
94
+ # report_on_exception makes Ruby print (and lets us reason about)
95
+ # exceptions that kill non-main threads; keep it on so background
96
+ # thread crashes are at least visible. Best-effort.
97
+ begin
98
+ Thread.report_on_exception = true if Thread.respond_to?(:report_on_exception=)
99
+ rescue StandardError
100
+ # never let observability config break startup
101
+ end
102
+ AllStak::GlobalHandler.install!(@logger)
103
+ end
59
104
  else
60
105
  @logger.warn("[AllStak] api_key not set — SDK not started")
61
106
  @client = nil
@@ -72,6 +117,32 @@ module AllStak
72
117
  @client or raise "AllStak not configured. Call AllStak.configure { |c| ... } first."
73
118
  end
74
119
 
120
+ def register_runtime_release(config, logger)
121
+ return unless config.auto_register_release
122
+ return if config.release.to_s.empty? || config.api_key.to_s.empty?
123
+ return if ENV["MT_TEST"] ||
124
+ ENV["RACK_ENV"] == "test" ||
125
+ ENV["RAILS_ENV"] == "test" ||
126
+ ENV["RUBYOPT"].to_s.include?("minitest") ||
127
+ $PROGRAM_NAME.to_s.include?("rspec")
128
+
129
+ thread = Thread.new do
130
+ begin
131
+ AllStak::Transport::HttpTransport.new(config, logger).post("/ingest/v1/releases", {
132
+ version: config.release,
133
+ environment: config.environment || "production",
134
+ commitSha: config.commit_sha,
135
+ branch: config.branch,
136
+ author: "#{config.sdk_name}/#{config.sdk_version}",
137
+ message: "Registered automatically by AllStak Ruby SDK at runtime"
138
+ })
139
+ rescue StandardError => e
140
+ logger.debug("[AllStak] runtime release registration failed: #{e.class}: #{e.message}") if logger
141
+ end
142
+ end
143
+ thread.abort_on_exception = false
144
+ end
145
+
75
146
  def capture_exception(exc, **kw)
76
147
  @client&.capture_exception(exc, **kw)
77
148
  end
@@ -87,6 +158,24 @@ module AllStak
87
158
  @client&.capture_message(message, level: level, **kw)
88
159
  end
89
160
 
161
+ # Manually capture an exception as a global, *unhandled* event
162
+ # (mechanism=at_exit, handled=false) and flush synchronously. Use this at
163
+ # the top of a worker/thread/process boundary where the framework would
164
+ # not otherwise route the exception through the Rack middleware. Safe
165
+ # no-op when the SDK is not configured.
166
+ def capture_unhandled(exc)
167
+ AllStak::GlobalHandler.capture_unhandled(exc)
168
+ end
169
+
170
+ # Manually record a breadcrumb on the current thread's ring buffer. It is
171
+ # attached to the next captured exception on this thread. Cross-SDK parity
172
+ # with JS addBreadcrumb / Python add_breadcrumb. Safe no-op when the SDK is
173
+ # not configured. Manual breadcrumbs are always recorded (independent of
174
+ # the enable_auto_breadcrumbs toggle, which only gates auto-instrumentation).
175
+ def add_breadcrumb(type:, message:, level: "info", data: nil)
176
+ @client&.errors&.add_breadcrumb(type: type, message: message, level: level, data: data)
177
+ end
178
+
90
179
  def set_user(**kw)
91
180
  @client&.set_user(**kw)
92
181
  end