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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/README.md +110 -233
- data/allstak.gemspec +10 -9
- data/lib/allstak/client.rb +66 -3
- data/lib/allstak/config.rb +259 -3
- data/lib/allstak/global_handler.rb +100 -0
- data/lib/allstak/integrations/active_record.rb +18 -0
- data/lib/allstak/integrations/logger.rb +201 -0
- data/lib/allstak/integrations/net_http.rb +27 -1
- data/lib/allstak/integrations/rack.rb +71 -10
- data/lib/allstak/integrations/rails.rb +59 -0
- data/lib/allstak/integrations/sidekiq.rb +184 -0
- data/lib/allstak/modules/database.rb +4 -1
- data/lib/allstak/modules/errors.rb +164 -22
- data/lib/allstak/modules/http_monitor.rb +7 -2
- data/lib/allstak/modules/logs.rb +28 -3
- data/lib/allstak/modules/tracing.rb +33 -2
- data/lib/allstak/propagation.rb +48 -0
- data/lib/allstak/sampling.rb +38 -0
- data/lib/allstak/sanitizer.rb +322 -0
- data/lib/allstak/session_tracker.rb +216 -0
- data/lib/allstak/transport/event_spool.rb +227 -0
- data/lib/allstak/transport/http_transport.rb +168 -5
- data/lib/allstak/version.rb +1 -1
- data/lib/allstak.rb +90 -1
- metadata +24 -29
data/lib/allstak/client.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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)
|
data/lib/allstak/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|