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.
@@ -1,15 +1,27 @@
1
1
  module AllStak
2
2
  # The AllStak SDK client. Create once via {AllStak.configure}.
3
3
  class Client
4
- attr_reader :config, :logger, :errors, :logs, :http, :tracing, :database, :cron, :tags, :contexts
4
+ attr_reader :config, :logger, :errors, :logs, :http, :tracing, :database, :cron, :tags, :contexts, :session_tracker
5
5
 
6
6
  def initialize(config, logger)
7
7
  @config = config
8
8
  @logger = logger
9
9
  @transport = Transport::HttpTransport.new(config, logger)
10
10
 
11
- @errors = Modules::Errors.new(@transport, config, logger)
12
- @logs = Modules::Logs.new(@transport, config, logger)
11
+ # Release-health session tracker: one session per process. Started by
12
+ # AllStak.configure after the release is resolved; ended in #shutdown.
13
+ @session_tracker = SessionTracker.new(config, @transport, logger)
14
+
15
+ @errors = Modules::Errors.new(@transport, config, logger,
16
+ session_id_provider: -> { @session_tracker&.current_session_id })
17
+ # Bridge AllStak.log.* into auto breadcrumbs (gated by the errors module
18
+ # on config.enable_auto_breadcrumbs). `auto: true` so it respects the
19
+ # toggle and never duplicates a manually-added breadcrumb.
20
+ errors = @errors
21
+ @logs = Modules::Logs.new(@transport, config, logger,
22
+ breadcrumb_sink: lambda do |**kw|
23
+ errors.add_breadcrumb(**kw, auto: true)
24
+ end)
13
25
  @http = Modules::HttpMonitor.new(@transport, config, logger)
14
26
  @tracing = Modules::Tracing.new(@transport, config, logger)
15
27
  @database = Modules::Database.new(@transport, config, logger)
@@ -20,6 +32,37 @@ module AllStak
20
32
  at_exit { shutdown rescue nil }
21
33
  end
22
34
 
35
+ # The active release-health session id (nil before start / after shutdown).
36
+ def current_session_id
37
+ @session_tracker&.current_session_id
38
+ end
39
+
40
+ # Begin the release-health session. Idempotent + fail-open. Called by
41
+ # AllStak.configure once the release has been finalized.
42
+ def start_session
43
+ @session_tracker&.start
44
+ end
45
+
46
+ # Replay any telemetry persisted by a previous process/outage. Runs on a
47
+ # daemon thread so init never blocks on the network; fully fail-open. Called
48
+ # by AllStak.configure after the client is built. No-op when the offline
49
+ # queue is disabled/unavailable.
50
+ def drain_offline_queue
51
+ transport = @transport
52
+ return unless transport.respond_to?(:drain_spool)
53
+ thread = Thread.new do
54
+ begin
55
+ transport.drain_spool
56
+ rescue StandardError => e
57
+ @logger&.debug("[AllStak] offline drain failed: #{e.class}: #{e.message}")
58
+ end
59
+ end
60
+ thread.abort_on_exception = false
61
+ self
62
+ rescue StandardError
63
+ self
64
+ end
65
+
23
66
  def set_user(id: nil, email: nil, ip: nil)
24
67
  @errors.set_user(id: id, email: email, ip: ip)
25
68
  end
@@ -30,11 +73,13 @@ module AllStak
30
73
 
31
74
  def capture_exception(exc, **kw)
32
75
  kw = merge_default_metadata(kw)
76
+ mark_session_for(kw)
33
77
  @errors.capture_exception(exc, **kw)
34
78
  end
35
79
 
36
80
  def capture_error(exception_class, message, **kw)
37
81
  kw = merge_default_metadata(kw)
82
+ mark_session_for(kw)
38
83
  @errors.capture_error(exception_class, message, **kw)
39
84
  end
40
85
 
@@ -80,10 +125,28 @@ module AllStak
80
125
  @http.shutdown
81
126
  @tracing.shutdown
82
127
  @database.shutdown
128
+ # Graceful shutdown: end the release-health session last so its
129
+ # /sessions/end carries the final accumulated status. Best-effort.
130
+ @session_tracker&.end rescue nil
83
131
  end
84
132
 
85
133
  private
86
134
 
135
+ # Mark the active session errored/crashed based on the captured event's
136
+ # mechanism. An at_exit/unhandled event (handled=false) is a crash;
137
+ # everything else is a handled error. Fail-open — never raises.
138
+ def mark_session_for(kw)
139
+ tracker = @session_tracker
140
+ return unless tracker
141
+ meta = kw[:metadata]
142
+ handled_false =
143
+ meta.is_a?(Hash) &&
144
+ (meta["handled"] == false || meta[:handled] == false)
145
+ handled_false ? tracker.record_crash : tracker.record_error
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
87
150
  # Fold the persistent tags + contexts into any explicit metadata caller
88
151
  # passed. Caller-supplied keys win on conflict.
89
152
  def merge_default_metadata(kw)
@@ -1,17 +1,215 @@
1
+ require_relative "version"
2
+
1
3
  module AllStak
4
+ # Runtime release detection from the local git checkout.
5
+ #
6
+ # The parsing logic is split from the actual shelling-out so it stays
7
+ # seamable: tests inject a fake "git runner" callable instead of relying on a
8
+ # real repository on disk.
9
+ #
10
+ # PRODUCTION HONESTY: a deployed artifact (packaged gem / container image)
11
+ # usually has no `.git` directory, so {.detect_release} returns nil there and
12
+ # the SDK version constant becomes the effective release. Runtime git
13
+ # detection mainly helps source/dev deployments running inside a checkout.
14
+ module GitRelease
15
+ module_function
16
+
17
+ # Default runner: shells out to the real `git` binary from the process
18
+ # working directory with a short timeout. Raises on any failure (git
19
+ # missing, no repo, timeout) so callers can treat every failure uniformly.
20
+ DEFAULT_RUNNER = lambda do |args|
21
+ require "open3"
22
+ require "timeout"
23
+ out = nil
24
+ Timeout.timeout(2) do
25
+ stdout, _stderr, status = Open3.capture3("git", *args)
26
+ raise "git exited #{status.exitstatus}" unless status.success?
27
+ out = stdout
28
+ end
29
+ out.to_s
30
+ end
31
+
32
+ # Best-effort release string from local git. Order:
33
+ # 1. `git describe --tags --always --dirty` (preferred).
34
+ # 2. else `git rev-parse --short HEAD`, with `-dirty` appended when
35
+ # `git status --porcelain` is non-empty.
36
+ # Fully guarded: returns nil if the runner raises or yields nothing for both
37
+ # strategies. Never raises.
38
+ def detect_release(runner = DEFAULT_RUNNER)
39
+ begin
40
+ described = runner.call(["describe", "--tags", "--always", "--dirty"]).to_s.strip
41
+ return described unless described.empty?
42
+ rescue StandardError
43
+ # fall through to rev-parse
44
+ end
45
+
46
+ begin
47
+ sha = runner.call(["rev-parse", "--short", "HEAD"]).to_s.strip
48
+ return nil if sha.empty?
49
+
50
+ status = begin
51
+ runner.call(["status", "--porcelain"]).to_s
52
+ rescue StandardError
53
+ ""
54
+ end
55
+ return "#{sha}-dirty" unless status.strip.empty?
56
+
57
+ sha
58
+ rescue StandardError
59
+ nil
60
+ end
61
+ end
62
+ end
63
+
2
64
  # SDK configuration. Populated via {AllStak.configure}.
3
65
  class Config
66
+ SDK_NAME = "allstak-ruby"
67
+ # Single source of truth: keep the wire `sdk.version` in lockstep with
68
+ # the gem version declared in version.rb.
69
+ SDK_VERSION = AllStak::VERSION
70
+
4
71
  attr_accessor :api_key, :host, :environment, :release, :service_name,
5
72
  :flush_interval_ms, :buffer_size, :debug,
6
73
  :connect_timeout, :read_timeout, :max_retries,
7
74
  :capture_unhandled_exceptions, :capture_http_requests,
8
- :capture_user_context, :capture_sql
75
+ :capture_user_context, :capture_sql,
76
+ # When true (default), the auto-instrumentation layers
77
+ # (inbound Rack requests, outbound Net::HTTP calls,
78
+ # ActiveRecord queries) and the AllStak.log.* bridge emit
79
+ # breadcrumbs into a 50-entry per-thread ring buffer that is
80
+ # attached to the next captured exception. Set false to
81
+ # disable automatic breadcrumb collection entirely (manual
82
+ # AllStak.add_breadcrumb still works). Honors
83
+ # ALLSTAK_AUTO_BREADCRUMBS=0/false.
84
+ :enable_auto_breadcrumbs,
85
+ # Install a process-wide `at_exit` hook that captures the
86
+ # exception terminating the process (global uncaught
87
+ # handler). Opt out by setting this false.
88
+ :install_at_exit_handler,
89
+ # Event pre-processing hook. A callable (proc/lambda) invoked
90
+ # once just before transport with a sanitized event Hash;
91
+ # returns a (possibly modified) event Hash, or nil to DROP the
92
+ # event. Runs for exception + message capture. The transport
93
+ # sanitizes again after the hook; fail-open sends the
94
+ # sanitized pre-callback event if the hook raises.
95
+ :before_send,
96
+ # Deterministic head-sampling for error/message events in
97
+ # [0.0, 1.0]. 1.0 keeps everything (default); 0.0 drops all.
98
+ :sample_rate,
99
+ # Span sampling rate in [0.0, 1.0]. nil disables span
100
+ # sampling entirely (every span kept, traceparent sampled
101
+ # flag stays "01" — the historical behavior). When set, span
102
+ # creation is sampled and the propagated traceparent sampled
103
+ # flag reflects the decision.
104
+ :traces_sample_rate,
105
+ # When true (default), and no explicit release or release env
106
+ # var is found, the SDK tries local git (git describe) once at
107
+ # init and finally falls back to the SDK version constant so
108
+ # release is never empty. Set false to disable the git +
109
+ # version-constant fallbacks (explicit + env still apply).
110
+ :auto_detect_release,
111
+ # When true (default), register the resolved release with
112
+ # AllStak at runtime startup without requiring CI/CD.
113
+ :auto_register_release,
114
+ # When true (default), the SDK tracks one release-health
115
+ # session per process: it POSTs /sessions/start on configure
116
+ # and /sessions/end on graceful shutdown (at_exit), and marks
117
+ # the session errored/crashed as events are captured. Set
118
+ # false to opt out entirely. Automatically disabled under a
119
+ # unit-test runtime.
120
+ :enable_auto_session_tracking,
121
+ # Offline/persistent event queue. When true (default), an
122
+ # un-deliverable PII-scrubbed envelope (network outage, retries
123
+ # exhausted, shutdown with events still buffered) is written to
124
+ # a filesystem spool and re-sent on the next SDK init. Best-
125
+ # effort + bounded; degrades silently to in-memory behavior
126
+ # when the spool dir is not writable (read-only FS, sandboxed,
127
+ # serverless). Set false to disable persistence entirely.
128
+ :enable_offline_queue,
129
+ # Spool directory. nil → <tmpdir>/allstak-spool. Configure to
130
+ # point at a durable, writable path (e.g. a mounted volume).
131
+ :offline_queue_dir,
132
+ # Spool bounds. Oldest entries are evicted first when over a
133
+ # limit. Sane server defaults live in EventSpool.
134
+ :offline_queue_max_entries,
135
+ :offline_queue_max_bytes,
136
+ :offline_queue_max_age_s,
137
+ # PII control. When FALSE (default), value-pattern
138
+ # scrubbing strips email + IP addresses from free-text values
139
+ # before they hit the wire, and any auto-collected client IP
140
+ # the SDK attaches is dropped. When TRUE, the caller has opted
141
+ # into PII: those tier-B value scrubbers are disabled and the
142
+ # auto-collected client IP is allowed through. High-risk
143
+ # financial/identity data (credit-card numbers passing Luhn,
144
+ # hyphenated US SSNs) is ALWAYS scrubbed regardless of this
145
+ # flag, and explicitly-set user data (setUser id/email/ip) is
146
+ # NEVER stripped. Honors ALLSTAK_SEND_DEFAULT_PII=1/true.
147
+ :send_default_pii,
148
+ # Extra key-name denylist terms (case-insensitive substring)
149
+ # merged into the canonical denylist on the wire path. Extends,
150
+ # never narrows. Honors ALLSTAK_EXTRA_DENYLIST (comma-separated).
151
+ :extra_denylist,
152
+ # Release-tracking metadata. All optional; we auto-detect
153
+ # the common ones from CI env vars below.
154
+ :dist, :commit_sha, :branch, :platform, :sdk_name, :sdk_version
155
+
156
+ # Process-wide cache so we shell out to git at most once. `false` means
157
+ # "resolved to nil" (distinct from "not yet resolved").
158
+ @git_release_cache = nil
159
+ @git_release_resolved = false
160
+
161
+ class << self
162
+ # Resolve the git-derived release once per process and cache it.
163
+ def cached_git_release
164
+ return @git_release_cache if @git_release_resolved
165
+
166
+ @git_release_resolved = true
167
+ @git_release_cache =
168
+ begin
169
+ GitRelease.detect_release
170
+ rescue StandardError
171
+ nil
172
+ end
173
+ end
174
+
175
+ # Test seam: reset the cache between examples.
176
+ def reset_git_release_cache!
177
+ @git_release_resolved = false
178
+ @git_release_cache = nil
179
+ end
180
+
181
+ # Test seam: pre-seed the resolved git release so #finalize_release! can be
182
+ # exercised without shelling out to a real repo.
183
+ def seed_git_release_cache!(value)
184
+ @git_release_resolved = true
185
+ @git_release_cache = value
186
+ end
187
+ end
9
188
 
10
189
  def initialize
11
190
  @api_key = ENV["ALLSTAK_API_KEY"].to_s
12
191
  @host = ENV["ALLSTAK_HOST"] || "https://api.allstak.sa"
13
- @environment = ENV["ALLSTAK_ENVIRONMENT"]
14
- @release = ENV["ALLSTAK_RELEASE"]
192
+ @environment = ENV["ALLSTAK_ENVIRONMENT"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "production"
193
+ # 1. explicit release is set via AllStak.configure after initialize.
194
+ # 2. env-var detection (unchanged).
195
+ @release = ENV["ALLSTAK_RELEASE"] ||
196
+ ENV["VERCEL_GIT_COMMIT_SHA"]&.slice(0, 12) ||
197
+ ENV["RAILWAY_GIT_COMMIT_SHA"]&.slice(0, 12) ||
198
+ ENV["RENDER_GIT_COMMIT"]&.slice(0, 12)
199
+ @auto_detect_release = true
200
+ @auto_register_release = true
201
+ @enable_auto_session_tracking = true
202
+ # Offline/persistent event queue: default ON for server runtimes; nil dir
203
+ # → <tmpdir>/allstak-spool. nil bound knobs fall back to EventSpool
204
+ # defaults. Honors ALLSTAK_OFFLINE_QUEUE=0/false and ALLSTAK_OFFLINE_QUEUE_DIR.
205
+ @enable_offline_queue = !%w[0 false no off].include?(ENV["ALLSTAK_OFFLINE_QUEUE"].to_s.strip.downcase)
206
+ @offline_queue_dir = ENV["ALLSTAK_OFFLINE_QUEUE_DIR"]
207
+ @offline_queue_max_entries = nil
208
+ @offline_queue_max_bytes = nil
209
+ @offline_queue_max_age_s = nil
210
+ # Default FALSE. Opt in via ALLSTAK_SEND_DEFAULT_PII.
211
+ @send_default_pii = %w[1 true yes on].include?(ENV["ALLSTAK_SEND_DEFAULT_PII"].to_s.strip.downcase)
212
+ @extra_denylist = parse_extra_denylist(ENV["ALLSTAK_EXTRA_DENYLIST"])
15
213
  @service_name = ENV["ALLSTAK_SERVICE"] || "ruby-service"
16
214
  @flush_interval_ms = 2_000
17
215
  @buffer_size = 500
@@ -23,14 +221,72 @@ module AllStak
23
221
  @capture_http_requests = true
24
222
  @capture_user_context = true
25
223
  @capture_sql = true
224
+ # Auto breadcrumbs: default ON for server runtimes. Honors
225
+ # ALLSTAK_AUTO_BREADCRUMBS=0/false.
226
+ @enable_auto_breadcrumbs = !%w[0 false no off].include?(ENV["ALLSTAK_AUTO_BREADCRUMBS"].to_s.strip.downcase)
227
+ @install_at_exit_handler = true
228
+ @before_send = nil
229
+ @sample_rate = 1.0
230
+ @traces_sample_rate = nil
231
+ # Release metadata
232
+ @platform = "ruby"
233
+ @sdk_name = SDK_NAME
234
+ @sdk_version = SDK_VERSION
235
+ @commit_sha = ENV["ALLSTAK_COMMIT_SHA"] || ENV["GIT_COMMIT"] || ENV["VERCEL_GIT_COMMIT_SHA"] ||
236
+ ENV["RAILWAY_GIT_COMMIT_SHA"] || ENV["RENDER_GIT_COMMIT"]
237
+ @branch = ENV["ALLSTAK_BRANCH"] || ENV["GIT_BRANCH"] || ENV["VERCEL_GIT_COMMIT_REF"] ||
238
+ ENV["RAILWAY_GIT_BRANCH"]
239
+ end
240
+
241
+ # Resolve the release after explicit user config has been applied (called
242
+ # from {AllStak.configure} once the user's block has run). Resolution order:
243
+ # 1. explicit @release (set by the user) — kept as-is.
244
+ # 2. release env var — already applied in #initialize.
245
+ # 3. local git (cached, guarded) when auto_detect_release.
246
+ # 4. SDK version constant when auto_detect_release.
247
+ # Never raises and never overwrites a non-empty release.
248
+ def finalize_release!
249
+ return self unless @release.nil? || @release.to_s.empty?
250
+ return self unless @auto_detect_release
251
+
252
+ @release = AllStak::Config.cached_git_release
253
+ @release = SDK_VERSION if @release.nil? || @release.to_s.empty?
254
+ self
255
+ end
256
+
257
+ # Release-tracking tags merged into every event payload's metadata.
258
+ def release_tags
259
+ tags = {}
260
+ tags["sdk.name"] = @sdk_name if @sdk_name
261
+ tags["sdk.version"] = @sdk_version if @sdk_version
262
+ tags["platform"] = @platform if @platform
263
+ tags["dist"] = @dist if @dist
264
+ tags["commit.sha"] = @commit_sha if @commit_sha
265
+ tags["commit.branch"] = @branch if @branch
266
+ tags
26
267
  end
27
268
 
28
269
  def valid?
29
270
  !@api_key.to_s.empty?
30
271
  end
31
272
 
273
+ # Idiomatic predicate for the PII toggle (coerces truthy config values).
274
+ def send_default_pii?
275
+ @send_default_pii ? true : false
276
+ end
277
+
32
278
  def host=(value)
33
279
  @host = value.to_s.sub(%r{/+\z}, "")
34
280
  end
281
+
282
+ private
283
+
284
+ # Parse a comma/space-separated env list into a clean array of terms.
285
+ # Returns nil when empty so the default canonical denylist is unaffected.
286
+ def parse_extra_denylist(raw)
287
+ return nil if raw.nil?
288
+ terms = raw.to_s.split(/[,\s]+/).map(&:strip).reject(&:empty?)
289
+ terms.empty? ? nil : terms
290
+ end
35
291
  end
36
292
  end
@@ -0,0 +1,100 @@
1
+ module AllStak
2
+ # Global uncaught-exception capture.
3
+ #
4
+ # Ruby has no first-class "uncaught exception" callback, but it runs
5
+ # `at_exit` blocks during interpreter teardown — and while they run, `$!`
6
+ # still holds the exception that is killing the process (if any). Inspecting
7
+ # `$!` from inside an `at_exit` block is the idiomatic way to catch a
8
+ # top-level unhandled exception. We capture it, mark it unhandled
9
+ # (mechanism handled=false), and do a best-effort synchronous flush before
10
+ # the process dies.
11
+ #
12
+ # We are deliberately conservative about WHAT counts as an unhandled
13
+ # termination so a clean exit (or a normal `exit`/`exit!`) is never reported
14
+ # as an error:
15
+ # - `$!` must be present and be an Exception.
16
+ # - SystemExit is treated as unhandled only when its status is non-zero
17
+ # (i.e. `exit(1)` / `abort`), never `exit(0)` / `exit` (clean exit).
18
+ # - SignalException (e.g. Ctrl-C / SIGINT) is ignored.
19
+ module GlobalHandler
20
+ MECHANISM = "at_exit".freeze
21
+
22
+ module_function
23
+
24
+ # Install the process-wide at_exit hook. Idempotent: the actual at_exit
25
+ # block is registered exactly once per process, regardless of how many
26
+ # times this is called (reconfigure, multiple configure calls, etc.).
27
+ # The block reads the live client at exit time, so reconfiguration is
28
+ # honored without re-registering.
29
+ def install!(logger = nil)
30
+ return if @installed
31
+ @installed = true
32
+ logger&.debug("[AllStak] installing at_exit unhandled-exception handler")
33
+ at_exit { run_at_exit($!) }
34
+ end
35
+
36
+ def installed?
37
+ @installed == true
38
+ end
39
+
40
+ # Test seam: forget that we installed (does NOT unregister the real
41
+ # at_exit block — Ruby has no API for that — but lets a test drive
42
+ # install!/idempotency logic deterministically).
43
+ def reset!
44
+ @installed = false
45
+ end
46
+
47
+ # The body of the at_exit hook, factored out so it is directly unit
48
+ # testable without actually terminating the process. `exc` is whatever
49
+ # `$!` held at exit time.
50
+ def run_at_exit(exc)
51
+ return unless AllStak.initialized?
52
+ return unless unhandled_termination?(exc)
53
+ capture_unhandled(exc)
54
+ end
55
+
56
+ # Decide whether `exc` represents a genuine unhandled termination that we
57
+ # should report, vs. a clean/expected exit we must ignore.
58
+ def unhandled_termination?(exc)
59
+ return false unless exc.is_a?(Exception)
60
+ # Ignore Ctrl-C / signal-driven teardown.
61
+ return false if exc.is_a?(SignalException)
62
+ # `exit`/`exit(0)` raise SystemExit with success? == true: clean exit.
63
+ if exc.is_a?(SystemExit)
64
+ return exc.respond_to?(:success?) ? !exc.success? : exc.status.to_i != 0
65
+ end
66
+ true
67
+ end
68
+
69
+ # Capture an exception as a global, unhandled event and flush
70
+ # synchronously. Safe to call directly as a documented integration point:
71
+ #
72
+ # begin
73
+ # run_worker
74
+ # rescue => e
75
+ # AllStak::GlobalHandler.capture_unhandled(e)
76
+ # raise
77
+ # end
78
+ #
79
+ # Also surfaced as {AllStak.capture_unhandled}.
80
+ def capture_unhandled(exc)
81
+ return nil unless AllStak.initialized?
82
+ client = AllStak.client
83
+ begin
84
+ client.capture_exception(
85
+ exc,
86
+ metadata: {
87
+ "mechanism" => MECHANISM,
88
+ "handled" => false
89
+ }
90
+ )
91
+ rescue => e
92
+ AllStak.logger&.debug("[AllStak] at_exit capture failed: #{e.class}: #{e.message}")
93
+ ensure
94
+ # Best-effort synchronous flush so buffered telemetry leaves the
95
+ # process before it dies. Never raise out of an at_exit hook.
96
+ client.flush rescue nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -60,6 +60,24 @@ module AllStak
60
60
  trace_id: client.tracing.current_trace_id,
61
61
  span_id: client.tracing.current_span_id
62
62
  )
63
+
64
+ # Query breadcrumb so the recent SQL trail lands on the next
65
+ # captured exception in this thread. The SQL is truncated for the
66
+ # breadcrumb message; value-pattern PII scrubbing still runs on the
67
+ # wire path. Auto-gated via config.enable_auto_breadcrumbs.
68
+ client.errors.add_breadcrumb(
69
+ type: "query",
70
+ message: sql.length > 300 ? "#{sql[0, 300]}…" : sql,
71
+ level: status == "error" ? "error" : "info",
72
+ data: {
73
+ "name" => name.empty? ? nil : name,
74
+ "durationMs" => event.duration.to_i,
75
+ "status" => status,
76
+ "db" => db_name,
77
+ "dbType" => db_type
78
+ }.reject { |_, v| v.nil? },
79
+ auto: true
80
+ )
63
81
  rescue => e
64
82
  # never raise into host
65
83
  AllStak.logger.debug("[AllStak] AR subscriber error: #{e.message}") rescue nil