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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +135 -0
- data/README.md +72 -240
- data/allstak.gemspec +10 -9
- data/lib/allstak/client.rb +58 -2
- data/lib/allstak/config.rb +246 -3
- data/lib/allstak/global_handler.rb +100 -0
- data/lib/allstak/integrations/net_http.rb +9 -1
- data/lib/allstak/integrations/rack.rb +54 -10
- data/lib/allstak/integrations/rails.rb +59 -0
- data/lib/allstak/integrations/sidekiq.rb +183 -0
- data/lib/allstak/modules/database.rb +4 -1
- data/lib/allstak/modules/errors.rb +84 -3
- data/lib/allstak/modules/http_monitor.rb +7 -2
- data/lib/allstak/modules/logs.rb +5 -2
- 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 +228 -0
- data/lib/allstak/transport/http_transport.rb +168 -5
- data/lib/allstak/version.rb +1 -1
- data/lib/allstak.rb +77 -1
- metadata +23 -29
|
@@ -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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
data/lib/allstak/version.rb
CHANGED
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
|
-
# #
|
|
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
|