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,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# AllStak Ruby SDK sanitizer.
|
|
4
|
+
#
|
|
5
|
+
# Provides recursive scrubbing of sensitive data across the full event surface
|
|
6
|
+
# (user, extras, metadata, breadcrumbs.data, contexts, request, response).
|
|
7
|
+
#
|
|
8
|
+
# Two complementary layers run on the wire path:
|
|
9
|
+
#
|
|
10
|
+
# 1. KEY-NAME redaction (always on): a case-insensitive substring match on
|
|
11
|
+
# Hash keys against the canonical denylist. Conforms to the canonical
|
|
12
|
+
# AllStak SDK denylist defined in docs/standards/sdk-platform-standards.md.
|
|
13
|
+
#
|
|
14
|
+
# 2. VALUE-PATTERN redaction (Sentry data-scrubbing parity): scans free-text
|
|
15
|
+
# *string values* for PII that leaks regardless of key name. Two tiers:
|
|
16
|
+
# A) ALWAYS scrubbed — credit-card numbers that pass the Luhn checksum,
|
|
17
|
+
# and US SSNs written with hyphens. High-risk financial/identity data
|
|
18
|
+
# never legitimately wanted in telemetry.
|
|
19
|
+
# B) Scrubbed UNLESS send_default_pii — email addresses and IPv4
|
|
20
|
+
# addresses. Default send_default_pii=false matches Sentry.
|
|
21
|
+
#
|
|
22
|
+
# Semantics:
|
|
23
|
+
# - Key match: case-insensitive substring match on Hash keys.
|
|
24
|
+
# - Value replacement with the sentinel string `[REDACTED]` (key preserved).
|
|
25
|
+
# - Recursion into Hash, Array; primitive values are passed through (with
|
|
26
|
+
# String values run through the value scrubbers per the rules above).
|
|
27
|
+
# - Cycle protection via an object_id Set.
|
|
28
|
+
# - Structural exemptions: certain keys/subtrees are never value-scrubbed
|
|
29
|
+
# (explicit user object, stack frames, release/sdk fields, URLs/paths,
|
|
30
|
+
# span/operation ids) — see VALUE_SCRUB_SKIP_KEYS / VALUE_SCRUB_SKIP_SUBTREES.
|
|
31
|
+
# - Pure: returns a sanitized copy; never mutates caller-owned structures.
|
|
32
|
+
# - Fail-open: value scrubbing never raises out of {.scrub}; on any error it
|
|
33
|
+
# falls back to the key-redacted-but-not-value-scrubbed structure.
|
|
34
|
+
|
|
35
|
+
require "set"
|
|
36
|
+
|
|
37
|
+
module AllStak
|
|
38
|
+
module Sanitizer
|
|
39
|
+
REDACTED = "[REDACTED]"
|
|
40
|
+
|
|
41
|
+
DEFAULT_DENYLIST = %w[
|
|
42
|
+
authorization
|
|
43
|
+
proxy-authorization
|
|
44
|
+
cookie
|
|
45
|
+
set-cookie
|
|
46
|
+
password
|
|
47
|
+
passwd
|
|
48
|
+
pwd
|
|
49
|
+
api_key
|
|
50
|
+
apikey
|
|
51
|
+
x-api-key
|
|
52
|
+
x-allstak-key
|
|
53
|
+
x-auth-token
|
|
54
|
+
x-access-token
|
|
55
|
+
token
|
|
56
|
+
bearer
|
|
57
|
+
jwt
|
|
58
|
+
session
|
|
59
|
+
sessionid
|
|
60
|
+
session_id
|
|
61
|
+
secret
|
|
62
|
+
credit_card
|
|
63
|
+
card_number
|
|
64
|
+
cvv
|
|
65
|
+
ssn
|
|
66
|
+
csrf
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
# Exact, CASE-SENSITIVE keys that look sensitive by substring but are NOT —
|
|
70
|
+
# they are first-class SDK telemetry fields that must survive scrubbing.
|
|
71
|
+
# The release-health `sessionId` (camelCase) carries the SDK's own
|
|
72
|
+
# per-process session id (a random UUID, not a user/auth session token);
|
|
73
|
+
# the backend error consumer needs it to attribute crashes, so it must
|
|
74
|
+
# never be redacted. Matched exactly and case-sensitively, so genuine
|
|
75
|
+
# cookie/auth keys like `session`, `session_id`, or `sessionid` (the
|
|
76
|
+
# lower-case denylist terms) are still scrubbed.
|
|
77
|
+
ALLOWLIST = %w[
|
|
78
|
+
sessionId
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
# --- value-pattern scrubbing configuration -----------------------------
|
|
82
|
+
|
|
83
|
+
# Longest single string we will scan for value patterns. Larger strings are
|
|
84
|
+
# passed through untouched so a pathological multi-MB blob never stalls the
|
|
85
|
+
# wire path. Key-name redaction still applies to its containing key.
|
|
86
|
+
MAX_SCAN_LENGTH = 16_384
|
|
87
|
+
|
|
88
|
+
# Keys whose *scalar* string value is exempt from value-pattern scrubbing
|
|
89
|
+
# (matched case-sensitively against the original key, then case-insensitively
|
|
90
|
+
# as a fallback). These carry structured identifiers / locations that the
|
|
91
|
+
# patterns would otherwise corrupt: stack-frame fields, release/sdk/build
|
|
92
|
+
# metadata, span & trace ids, URLs/paths (their own URL redactor owns them).
|
|
93
|
+
VALUE_SCRUB_SKIP_KEYS = %w[
|
|
94
|
+
filename
|
|
95
|
+
function
|
|
96
|
+
abspath
|
|
97
|
+
abs_path
|
|
98
|
+
lineno
|
|
99
|
+
colno
|
|
100
|
+
release
|
|
101
|
+
version
|
|
102
|
+
dist
|
|
103
|
+
platform
|
|
104
|
+
environment
|
|
105
|
+
sdkname
|
|
106
|
+
sdk_name
|
|
107
|
+
sdkversion
|
|
108
|
+
sdk_version
|
|
109
|
+
sdk.name
|
|
110
|
+
sdk.version
|
|
111
|
+
commit.sha
|
|
112
|
+
commit.branch
|
|
113
|
+
commit_sha
|
|
114
|
+
url
|
|
115
|
+
path
|
|
116
|
+
host
|
|
117
|
+
hostname
|
|
118
|
+
route
|
|
119
|
+
operation
|
|
120
|
+
op
|
|
121
|
+
spanid
|
|
122
|
+
span_id
|
|
123
|
+
parentspanid
|
|
124
|
+
parent_span_id
|
|
125
|
+
traceid
|
|
126
|
+
trace_id
|
|
127
|
+
requestid
|
|
128
|
+
request_id
|
|
129
|
+
sessionid
|
|
130
|
+
sessionId
|
|
131
|
+
timestamp
|
|
132
|
+
].each_with_object({}) { |k, h| h[k.downcase] = true }.freeze
|
|
133
|
+
|
|
134
|
+
# Top-level subtrees that are never value-scrubbed. `user` holds data the
|
|
135
|
+
# caller explicitly set via setUser (intentional identification — ships as
|
|
136
|
+
# before, matching Sentry). `frames`/`stackTrace` hold structured stack
|
|
137
|
+
# frames whose filenames/functions must not be corrupted.
|
|
138
|
+
VALUE_SCRUB_SKIP_SUBTREES = %w[
|
|
139
|
+
user
|
|
140
|
+
frames
|
|
141
|
+
stackTrace
|
|
142
|
+
stacktrace
|
|
143
|
+
].each_with_object({}) { |k, h| h[k.downcase] = true }.freeze
|
|
144
|
+
|
|
145
|
+
# US SSN — REQUIRE the hyphens so bare 9-digit numbers (order ids, etc.)
|
|
146
|
+
# are not nuked. Compiled once.
|
|
147
|
+
SSN_REGEX = /\b\d{3}-\d{2}-\d{4}\b/.freeze
|
|
148
|
+
|
|
149
|
+
# Candidate credit-card runs: 13–19 digits with optional single space/hyphen
|
|
150
|
+
# separators between groups. Luhn-validated before redaction (see #luhn?),
|
|
151
|
+
# so digit runs that fail the checksum (timestamps, order ids) survive.
|
|
152
|
+
# Word-boundary-ish anchors keep us from matching the middle of a longer
|
|
153
|
+
# digit string.
|
|
154
|
+
CC_CANDIDATE_REGEX = /(?<![\d-])(?:\d[ -]?){12,18}\d(?![\d-])/.freeze
|
|
155
|
+
|
|
156
|
+
# Standard email address. Compiled once.
|
|
157
|
+
EMAIL_REGEX = /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/.freeze
|
|
158
|
+
|
|
159
|
+
# IPv4 with each octet validated to 0–255. Compiled once.
|
|
160
|
+
IPV4_OCTET = '(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)'
|
|
161
|
+
IPV4_REGEX = /\b#{IPV4_OCTET}\.#{IPV4_OCTET}\.#{IPV4_OCTET}\.#{IPV4_OCTET}\b/.freeze
|
|
162
|
+
|
|
163
|
+
# IPv6 best-effort: 2+ groups of hex separated by colons, with optional ::
|
|
164
|
+
# compression. Intentionally loose — IPv6 detection is best-effort per spec.
|
|
165
|
+
IPV6_REGEX = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{0,4}\b|\b::(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}\b/.freeze
|
|
166
|
+
|
|
167
|
+
module_function
|
|
168
|
+
|
|
169
|
+
# Returns a sanitized deep copy of `payload`.
|
|
170
|
+
#
|
|
171
|
+
# @param extra_denylist [Array<String>, nil] additional key terms to redact;
|
|
172
|
+
# may extend but not narrow the canonical list.
|
|
173
|
+
# @param send_default_pii [Boolean] when true, the tier-B value scrubbers
|
|
174
|
+
# (email, IPv4/IPv6) are disabled — the caller has opted into PII. Tier-A
|
|
175
|
+
# (credit card, SSN) is ALWAYS applied. Default false (Sentry parity).
|
|
176
|
+
# @param values [Boolean] when false, only key-name redaction runs (no
|
|
177
|
+
# value-pattern scrubbing). Useful for an intermediate pre-scrub (e.g.
|
|
178
|
+
# Sidekiq job args) where the wire-path scrub will value-scrub later with
|
|
179
|
+
# the authoritative config. Default true.
|
|
180
|
+
def scrub(payload, extra_denylist: nil, send_default_pii: false, values: true)
|
|
181
|
+
denylist = DEFAULT_DENYLIST.dup
|
|
182
|
+
denylist.concat(extra_denylist.map { |t| t.to_s.downcase }) if extra_denylist
|
|
183
|
+
denylist.uniq!
|
|
184
|
+
return walk_keys_only(payload, denylist, Set.new) unless values
|
|
185
|
+
walk(payload, denylist, Set.new, send_default_pii)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def sensitive?(key, denylist)
|
|
189
|
+
return false unless key.is_a?(String) || key.is_a?(Symbol)
|
|
190
|
+
|
|
191
|
+
# Exact, case-sensitive allowlist wins: a first-class SDK field (e.g.
|
|
192
|
+
# release-health `sessionId`) is never scrubbed even though its lowercase
|
|
193
|
+
# form contains a denied substring. Checked against the ORIGINAL key so
|
|
194
|
+
# `sessionId` survives while `sessionid`/`session_id`/`session` are scrubbed.
|
|
195
|
+
return false if ALLOWLIST.include?(key.to_s)
|
|
196
|
+
|
|
197
|
+
k = key.to_s.downcase
|
|
198
|
+
denylist.any? { |term| k.include?(term) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def walk(value, denylist, seen, send_default_pii)
|
|
202
|
+
case value
|
|
203
|
+
when Hash
|
|
204
|
+
return REDACTED if seen.include?(value.object_id)
|
|
205
|
+
|
|
206
|
+
seen.add(value.object_id)
|
|
207
|
+
value.each_with_object({}) do |(k, v), out|
|
|
208
|
+
out[k] =
|
|
209
|
+
if sensitive?(k, denylist)
|
|
210
|
+
REDACTED
|
|
211
|
+
elsif skip_subtree?(k)
|
|
212
|
+
# Explicit user object / stack frames: deep-copy with key-name
|
|
213
|
+
# redaction still applied, but NO value-pattern scrubbing.
|
|
214
|
+
walk_keys_only(v, denylist, seen)
|
|
215
|
+
elsif skip_value_scrub_key?(k)
|
|
216
|
+
# Structured scalar (release, url, span id, …): recurse for nested
|
|
217
|
+
# collections, but do not value-scrub a scalar string here.
|
|
218
|
+
v.is_a?(Hash) || v.is_a?(Array) ? walk(v, denylist, seen, send_default_pii) : v
|
|
219
|
+
else
|
|
220
|
+
walk(v, denylist, seen, send_default_pii)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
when Array
|
|
224
|
+
return REDACTED if seen.include?(value.object_id)
|
|
225
|
+
|
|
226
|
+
seen.add(value.object_id)
|
|
227
|
+
value.map { |v| walk(v, denylist, seen, send_default_pii) }
|
|
228
|
+
when String
|
|
229
|
+
scrub_value(value, send_default_pii)
|
|
230
|
+
else
|
|
231
|
+
value
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Recurse applying ONLY key-name redaction (no value-pattern scrubbing).
|
|
236
|
+
# Used for exempt subtrees (explicit user object, stack frames).
|
|
237
|
+
def walk_keys_only(value, denylist, seen)
|
|
238
|
+
case value
|
|
239
|
+
when Hash
|
|
240
|
+
return REDACTED if seen.include?(value.object_id)
|
|
241
|
+
|
|
242
|
+
seen.add(value.object_id)
|
|
243
|
+
value.each_with_object({}) do |(k, v), out|
|
|
244
|
+
out[k] = sensitive?(k, denylist) ? REDACTED : walk_keys_only(v, denylist, seen)
|
|
245
|
+
end
|
|
246
|
+
when Array
|
|
247
|
+
return REDACTED if seen.include?(value.object_id)
|
|
248
|
+
|
|
249
|
+
seen.add(value.object_id)
|
|
250
|
+
value.map { |v| walk_keys_only(v, denylist, seen) }
|
|
251
|
+
else
|
|
252
|
+
value
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def skip_subtree?(key)
|
|
257
|
+
return false unless key.is_a?(String) || key.is_a?(Symbol)
|
|
258
|
+
VALUE_SCRUB_SKIP_SUBTREES.key?(key.to_s.downcase)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def skip_value_scrub_key?(key)
|
|
262
|
+
return false unless key.is_a?(String) || key.is_a?(Symbol)
|
|
263
|
+
VALUE_SCRUB_SKIP_KEYS.key?(key.to_s.downcase)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Apply value-pattern scrubbing to a single string. Fail-open: any error
|
|
267
|
+
# returns the original string. Oversized strings are passed through.
|
|
268
|
+
def scrub_value(str, send_default_pii)
|
|
269
|
+
return str unless str.is_a?(String)
|
|
270
|
+
return str if str.empty? || str.length > MAX_SCAN_LENGTH
|
|
271
|
+
|
|
272
|
+
out = str
|
|
273
|
+
|
|
274
|
+
# Tier A — ALWAYS (regardless of send_default_pii).
|
|
275
|
+
out = out.gsub(SSN_REGEX, REDACTED)
|
|
276
|
+
out = scrub_credit_cards(out)
|
|
277
|
+
|
|
278
|
+
# Tier B — only when the caller has NOT opted into PII.
|
|
279
|
+
unless send_default_pii
|
|
280
|
+
out = out.gsub(EMAIL_REGEX, REDACTED)
|
|
281
|
+
out = out.gsub(IPV4_REGEX, REDACTED)
|
|
282
|
+
out = out.gsub(IPV6_REGEX, REDACTED)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
out
|
|
286
|
+
rescue StandardError
|
|
287
|
+
str
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Replace only those candidate credit-card runs that pass the Luhn checksum.
|
|
291
|
+
# A run that fails Luhn (e.g. an order id or timestamp that happens to be
|
|
292
|
+
# 13–19 digits) is left intact, minimizing over-redaction.
|
|
293
|
+
def scrub_credit_cards(str)
|
|
294
|
+
str.gsub(CC_CANDIDATE_REGEX) do |match|
|
|
295
|
+
digits = match.gsub(/[ -]/, "")
|
|
296
|
+
if digits.length.between?(13, 19) && luhn?(digits)
|
|
297
|
+
REDACTED
|
|
298
|
+
else
|
|
299
|
+
match
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Luhn (mod-10) checksum over a string of digits.
|
|
305
|
+
def luhn?(digits)
|
|
306
|
+
return false unless digits =~ /\A\d{13,19}\z/
|
|
307
|
+
|
|
308
|
+
sum = 0
|
|
309
|
+
double = false
|
|
310
|
+
digits.reverse.each_char do |ch|
|
|
311
|
+
d = ch.to_i
|
|
312
|
+
if double
|
|
313
|
+
d *= 2
|
|
314
|
+
d -= 9 if d > 9
|
|
315
|
+
end
|
|
316
|
+
sum += d
|
|
317
|
+
double = !double
|
|
318
|
+
end
|
|
319
|
+
(sum % 10).zero?
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require_relative "transport/http_transport"
|
|
3
|
+
|
|
4
|
+
module AllStak
|
|
5
|
+
# Server-mode "single session" release-health tracker.
|
|
6
|
+
#
|
|
7
|
+
# Mirrors the AllStak Java SDK `SessionTracker` lifecycle + status model:
|
|
8
|
+
# on {#start} the SDK posts a `/ingest/v1/sessions/start` envelope with the
|
|
9
|
+
# process's distinct session id, the resolved release, and SDK identity. On
|
|
10
|
+
# {#end} it posts `/ingest/v1/sessions/end` with the final status + total
|
|
11
|
+
# duration. ERRORED / CRASHED transitions are recorded in-memory only; only
|
|
12
|
+
# the terminal {#end} call performs network I/O for status, so per-error
|
|
13
|
+
# latency stays unaffected.
|
|
14
|
+
#
|
|
15
|
+
# One instance per {AllStak::Client}. Re-entrancy safe: once started a second
|
|
16
|
+
# {#start} is a no-op; once ended the tracker does not re-arm.
|
|
17
|
+
#
|
|
18
|
+
# Sessions are NEVER sampled — they are always sent (when tracking is on and a
|
|
19
|
+
# release is resolvable). The whole tracker is fully fail-open: a network
|
|
20
|
+
# failure or any other error must never crash app boot or shutdown.
|
|
21
|
+
class SessionTracker
|
|
22
|
+
PATH_START = "/ingest/v1/sessions/start".freeze
|
|
23
|
+
PATH_END = "/ingest/v1/sessions/end".freeze
|
|
24
|
+
|
|
25
|
+
# Lifecycle status. Vocabulary matches the backend `/sessions/end` contract
|
|
26
|
+
# and Sentry's release-health conventions:
|
|
27
|
+
# ok — ended normally, at most non-fatal logs.
|
|
28
|
+
# errored — at least one HANDLED error captured; process kept running.
|
|
29
|
+
# crashed — an UNHANDLED/fatal exception ended the process.
|
|
30
|
+
# abnormal — ended without a normal flush (reserved).
|
|
31
|
+
STATUS_OK = "ok".freeze
|
|
32
|
+
STATUS_ERRORED = "errored".freeze
|
|
33
|
+
STATUS_CRASHED = "crashed".freeze
|
|
34
|
+
STATUS_ABNORMAL = "abnormal".freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :session_id, :started_at
|
|
37
|
+
|
|
38
|
+
def initialize(config, transport, logger = nil)
|
|
39
|
+
@config = config
|
|
40
|
+
@transport = transport
|
|
41
|
+
@logger = logger
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@session_id = nil
|
|
44
|
+
@started_at = nil
|
|
45
|
+
@status = STATUS_OK
|
|
46
|
+
@error_count = 0
|
|
47
|
+
@started = false
|
|
48
|
+
@ended = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Should this runtime track sessions at all? Off when the user opted out via
|
|
52
|
+
# `enable_auto_session_tracking = false`, and automatically off under a unit
|
|
53
|
+
# test runtime (mirrors the Java SDK's test guard) so the suite never emits
|
|
54
|
+
# session traffic.
|
|
55
|
+
def enabled?
|
|
56
|
+
return false unless @config.enable_auto_session_tracking
|
|
57
|
+
!self.class.test_runtime?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Detect a unit-test runtime so session tracking self-disables there,
|
|
61
|
+
# matching {AllStak.register_runtime_release}'s own guard.
|
|
62
|
+
def self.test_runtime?
|
|
63
|
+
return true if ENV["MT_TEST"]
|
|
64
|
+
return true if ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test"
|
|
65
|
+
return true if ENV["RUBYOPT"].to_s.include?("minitest")
|
|
66
|
+
return true if $PROGRAM_NAME.to_s.include?("rspec")
|
|
67
|
+
defined?(Minitest) ? true : false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Idempotent. Records sessionStart, sets in-memory status = "ok", and POSTs
|
|
71
|
+
# `/sessions/start` on a daemon thread so SDK init never blocks on a network
|
|
72
|
+
# round-trip. No-op when tracking is disabled, the transport is disabled, or
|
|
73
|
+
# no release/sdkVersion can be resolved. Never raises.
|
|
74
|
+
def start
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
return self if @started
|
|
77
|
+
@started = true
|
|
78
|
+
@session_id = SecureRandom.uuid
|
|
79
|
+
@started_at = now_ms
|
|
80
|
+
@status = STATUS_OK
|
|
81
|
+
@error_count = 0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
return self unless enabled?
|
|
85
|
+
return self if transport_disabled?
|
|
86
|
+
|
|
87
|
+
release = effective_release
|
|
88
|
+
return self if release.to_s.empty?
|
|
89
|
+
|
|
90
|
+
payload = {
|
|
91
|
+
sessionId: @session_id,
|
|
92
|
+
release: release,
|
|
93
|
+
environment: @config.environment,
|
|
94
|
+
userId: current_user_id,
|
|
95
|
+
sdkName: @config.sdk_name,
|
|
96
|
+
sdkVersion: @config.sdk_version,
|
|
97
|
+
platform: @config.platform
|
|
98
|
+
}.compact
|
|
99
|
+
|
|
100
|
+
send_async(PATH_START, payload, "session start")
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The active session id, or nil before start / after end. Attached to every
|
|
105
|
+
# error/event payload so the backend can mark the session errored/crashed.
|
|
106
|
+
def current_session_id
|
|
107
|
+
@mutex.synchronize { (@started && !@ended) ? @session_id : nil }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Record a HANDLED error: bump status ok -> errored (never downgrades a
|
|
111
|
+
# terminal crash). No I/O.
|
|
112
|
+
def record_error
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
next unless active_locked?
|
|
115
|
+
@error_count += 1
|
|
116
|
+
@status = STATUS_ERRORED if @status == STATUS_OK
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Record an UNHANDLED/fatal crash: terminal status (overrides errored).
|
|
121
|
+
# No I/O — the {#end} POST carries the status.
|
|
122
|
+
def record_crash
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
next unless active_locked?
|
|
125
|
+
@error_count += 1
|
|
126
|
+
@status = STATUS_CRASHED
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Terminate the session and POST `/sessions/end` with durationMs + status.
|
|
131
|
+
# Idempotent. Best-effort with a short timeout; must not block or raise.
|
|
132
|
+
# `final_status` overrides the accumulated status when given.
|
|
133
|
+
def end(final_status = nil)
|
|
134
|
+
sid = nil
|
|
135
|
+
status = nil
|
|
136
|
+
duration = nil
|
|
137
|
+
@mutex.synchronize do
|
|
138
|
+
return if @ended || !@started
|
|
139
|
+
@ended = true
|
|
140
|
+
sid = @session_id
|
|
141
|
+
status = final_status || @status
|
|
142
|
+
duration = [now_ms - @started_at.to_i, 0].max
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return unless enabled?
|
|
146
|
+
return if transport_disabled?
|
|
147
|
+
return if effective_release.to_s.empty?
|
|
148
|
+
|
|
149
|
+
payload = {
|
|
150
|
+
sessionId: sid,
|
|
151
|
+
durationMs: clamp_int(duration),
|
|
152
|
+
status: status
|
|
153
|
+
}.compact
|
|
154
|
+
|
|
155
|
+
send_sync(PATH_END, payload, "session end")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def active_locked?
|
|
161
|
+
@started && !@ended
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def now_ms
|
|
165
|
+
(Time.now.to_f * 1000).to_i
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def clamp_int(value)
|
|
169
|
+
v = value.to_i
|
|
170
|
+
v > 2_147_483_647 ? 2_147_483_647 : v
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Release is REQUIRED by the backend; fall back to the SDK version when no
|
|
174
|
+
# release is resolved so release-health attribution still has a key.
|
|
175
|
+
def effective_release
|
|
176
|
+
rel = @config.release
|
|
177
|
+
rel = @config.sdk_version if rel.to_s.empty?
|
|
178
|
+
rel
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def current_user_id
|
|
182
|
+
uid = @config.respond_to?(:user_id) ? @config.user_id : nil
|
|
183
|
+
uid.to_s.empty? ? nil : uid.to_s
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def transport_disabled?
|
|
187
|
+
@transport.respond_to?(:disabled?) && @transport.disabled?
|
|
188
|
+
rescue StandardError
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# POST off the hot/boot path on a daemon thread. Fail-open.
|
|
193
|
+
def send_async(path, payload, label)
|
|
194
|
+
thread = Thread.new do
|
|
195
|
+
begin
|
|
196
|
+
@transport.post(path, payload)
|
|
197
|
+
@logger&.debug("[AllStak] #{label}: #{payload[:sessionId]}")
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
@logger&.debug("[AllStak] #{label} failed: #{e.class}: #{e.message}")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
thread.abort_on_exception = false
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
@logger&.debug("[AllStak] #{label} could not start: #{e.class}: #{e.message}")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Synchronous best-effort POST for shutdown — the process may exit before a
|
|
208
|
+
# background thread runs, so end is sent inline. Never raises.
|
|
209
|
+
def send_sync(path, payload, label)
|
|
210
|
+
@transport.post(path, payload)
|
|
211
|
+
@logger&.debug("[AllStak] #{label}: #{payload[:sessionId]} status=#{payload[:status]}")
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
@logger&.debug("[AllStak] #{label} failed: #{e.class}: #{e.message}")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|