allstak 0.3.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea16fc5869d09516a29301382b4d59d9e3f33018226516beb208aa1c826a77f3
4
- data.tar.gz: 6b73ffb1e1319585c2be4c7398181cf9beae32a4ea17914501df6ae1a5df2a66
3
+ metadata.gz: 6749cec382749239156fb82d5feb81db7c0c63916d06f037ec6d3fbcea037a53
4
+ data.tar.gz: ae060d2498ee40e52b6c4ce26dc5fc283bfeff5c9ab0756fff1ecdcccf26ebad
5
5
  SHA512:
6
- metadata.gz: 1a21a2da36cf561691221369b9cf25fdfc4063300fc3f32452652d6f0987044b9121d719a597db9d6b18d617583e0253e8ee59f08539c8f2ed3c1efe5ffd543a
7
- data.tar.gz: 43e2c1d26dd502e77fe498e4850352235a63be28fa33ac5c2d61a4ee7171cafda55bc11285d781de96664cdb962becf4df387c83573ccdfa495de3e80fe05695
6
+ metadata.gz: 26a946694cf36a20b64023727ca4f9b8c0a8fab9248d6481f527252ad879bb83b9901e5349524b11156bf959ef6eec7cf1b09deb21bfa5f0e57149cba21f0f2a
7
+ data.tar.gz: c1dbec9f5fb40f38a2aca6ac7f52a5515edd9ce6b87dd8702030fdf6d334b8f3cf3b102ea24260a30bb9d7219b177a7f14a3158e8499cdb6c29ee0ffc020f689
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## [0.3.1] — 2026-06-06
9
+
10
+ ### Fixed
11
+
12
+ - Treated terminal non-429 4xx ingest responses, including feature-gated `402`
13
+ responses, as non-retryable so offline replay cannot wedge permanently.
14
+
8
15
  ## [0.3.0] — 2026-05-30
9
16
 
10
17
  ### Added — Automatic breadcrumbs across all instrumentation
@@ -1,3 +1,5 @@
1
+ require_relative "diagnostics"
2
+
1
3
  module AllStak
2
4
  # The AllStak SDK client. Create once via {AllStak.configure}.
3
5
  class Client
@@ -130,8 +132,49 @@ module AllStak
130
132
  @session_tracker&.end rescue nil
131
133
  end
132
134
 
135
+ # Privacy-safe SDK diagnostics. Contains counters and queue sizes only.
136
+ def diagnostics
137
+ transport_stats =
138
+ if @transport.respond_to?(:diagnostics)
139
+ @transport.diagnostics
140
+ else
141
+ {}
142
+ end
143
+ modules = [@errors, @logs, @http, @tracing, @database]
144
+ buffer_queue = modules.sum { |m| safe_count(m, :buffer_count) }
145
+ buffer_drops = modules.sum { |m| safe_count(m, :dropped_count) }
146
+
147
+ Diagnostics.new(
148
+ events_captured: modules.sum { |m| safe_count(m, :captured_count) },
149
+ events_sent: transport_stats.fetch(:sent, 0),
150
+ events_failed: transport_stats.fetch(:failed, 0),
151
+ events_dropped: transport_stats.fetch(:dropped, 0) + buffer_drops,
152
+ events_persisted: transport_stats.fetch(:persisted, 0),
153
+ events_replayed: transport_stats.fetch(:replayed, 0),
154
+ queue_size: transport_stats.fetch(:queue_size, 0) + buffer_queue,
155
+ retry_attempts: transport_stats.fetch(:retry_attempts, 0),
156
+ rate_limited_count: transport_stats.fetch(:rate_limited, 0),
157
+ compressed_payloads: transport_stats.fetch(:compressed, 0),
158
+ uncompressed_payloads: transport_stats.fetch(:uncompressed, 0),
159
+ compression_bytes_saved: transport_stats.fetch(:compression_bytes_saved, 0),
160
+ sanitizer_redaction_count: AllStak::Sanitizer.redaction_count,
161
+ active_trace_count: safe_count(@tracing, :active_trace_count),
162
+ active_span_count: safe_count(@tracing, :active_span_count),
163
+ breadcrumb_count: safe_count(@errors, :breadcrumb_count),
164
+ session_recovery_count: safe_count(@session_tracker, :recovery_count),
165
+ disabled: @transport.disabled?
166
+ )
167
+ end
168
+
133
169
  private
134
170
 
171
+ def safe_count(target, method_name)
172
+ return 0 unless target.respond_to?(method_name)
173
+ target.public_send(method_name).to_i
174
+ rescue StandardError
175
+ 0
176
+ end
177
+
135
178
  # Mark the active session errored/crashed based on the captured event's
136
179
  # mechanism. An at_exit/unhandled event (handled=false) is a crash;
137
180
  # everything else is a handled error. Fail-open — never raises.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AllStak
4
+ # Privacy-safe SDK diagnostics snapshot.
5
+ #
6
+ # Contains counters and queue sizes only. It intentionally never includes
7
+ # telemetry payloads, headers, tags, context values, user data, or breadcrumbs.
8
+ class Diagnostics
9
+ ATTRIBUTES = [
10
+ :events_captured,
11
+ :events_sent,
12
+ :events_failed,
13
+ :events_dropped,
14
+ :events_persisted,
15
+ :events_replayed,
16
+ :queue_size,
17
+ :retry_attempts,
18
+ :rate_limited_count,
19
+ :compressed_payloads,
20
+ :uncompressed_payloads,
21
+ :compression_bytes_saved,
22
+ :sanitizer_redaction_count,
23
+ :active_trace_count,
24
+ :active_span_count,
25
+ :breadcrumb_count,
26
+ :session_recovery_count,
27
+ :disabled
28
+ ].freeze
29
+
30
+ attr_reader(*ATTRIBUTES)
31
+
32
+ def initialize(values = {})
33
+ ATTRIBUTES.each do |name|
34
+ default = name == :disabled ? false : 0
35
+ instance_variable_set("@#{name}", values.fetch(name, default))
36
+ end
37
+ end
38
+
39
+ def to_h
40
+ ATTRIBUTES.each_with_object({}) do |name, out|
41
+ out[name] = public_send(name)
42
+ end
43
+ end
44
+
45
+ alias to_hash to_h
46
+ end
47
+ end
@@ -23,14 +23,16 @@ module AllStak
23
23
  start = now_ms
24
24
  started_at = Time.now.utc.iso8601(3)
25
25
 
26
- trace_id = trace_id_from_env(env)
27
- parent_span_id = parent_span_id_from_env(env)
26
+ incoming_trace = trace_context_from_env(env)
27
+ trace_id = incoming_trace[:trace_id]
28
+ parent_span_id = incoming_trace[:parent_span_id]
28
29
  request_id = env["HTTP_X_REQUEST_ID"] || env["HTTP_X_ALLSTAK_REQUEST_ID"] || SecureRandom.hex(16)
29
30
  if trace_id && !trace_id.empty?
30
31
  client.tracing.set_trace_id(trace_id)
31
32
  else
32
33
  client.tracing.reset_trace
33
34
  end
35
+ client.tracing.set_parent_span_id(parent_span_id) if parent_span_id && client.tracing.respond_to?(:set_parent_span_id)
34
36
  trace_id = client.tracing.current_trace_id
35
37
  span = client.tracing.start_span(
36
38
  "http.server",
@@ -154,18 +156,23 @@ module AllStak
154
156
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
155
157
  end
156
158
 
157
- def trace_id_from_env(env)
158
- traceparent = env["HTTP_TRACEPARENT"].to_s
159
- parts = traceparent.split("-")
160
- return parts[1] if parts.length >= 2 && parts[1].length == 32
161
- env["HTTP_X_ALLSTAK_TRACE_ID"] || env["HTTP_X_TRACE_ID"]
159
+ def trace_context_from_env(env)
160
+ parsed = AllStak::Propagation.parse_traceparent(env["HTTP_TRACEPARENT"])
161
+ return parsed if parsed
162
+
163
+ trace_id = valid_trace_header(env["HTTP_X_ALLSTAK_TRACE_ID"]) || valid_trace_header(env["HTTP_X_TRACE_ID"])
164
+ parent_span_id = trace_id ? (valid_span_header(env["HTTP_X_ALLSTAK_SPAN_ID"]) || valid_span_header(env["HTTP_X_SPAN_ID"])) : nil
165
+ { trace_id: trace_id, parent_span_id: parent_span_id, sampled: nil }
166
+ end
167
+
168
+ def valid_trace_header(value)
169
+ normalized = value.to_s.downcase
170
+ AllStak::Propagation.valid_trace_id?(normalized) ? normalized : nil
162
171
  end
163
172
 
164
- def parent_span_id_from_env(env)
165
- traceparent = env["HTTP_TRACEPARENT"].to_s
166
- parts = traceparent.split("-")
167
- return parts[2] if parts.length >= 3 && parts[2].length == 16
168
- env["HTTP_X_ALLSTAK_SPAN_ID"] || env["HTTP_X_SPAN_ID"]
173
+ def valid_span_header(value)
174
+ normalized = value.to_s.downcase
175
+ AllStak::Propagation.valid_span_id?(normalized) ? normalized : nil
169
176
  end
170
177
 
171
178
  def extract_user_id(env)
@@ -11,6 +11,7 @@ module AllStak
11
11
  @transport = transport
12
12
  @config = config
13
13
  @logger = logger
14
+ @captured_count = 0
14
15
  @buffer = Transport::FlushBuffer.new(
15
16
  name: "database",
16
17
  max_size: config.buffer_size,
@@ -42,6 +43,7 @@ module AllStak
42
43
  spanId: span_id,
43
44
  rowsAffected: rows_affected
44
45
  }.compact)
46
+ @captured_count += 1
45
47
  end
46
48
 
47
49
  def flush
@@ -69,6 +71,18 @@ module AllStak
69
71
  %w[SELECT INSERT UPDATE DELETE].include?(first) ? first : "OTHER"
70
72
  end
71
73
 
74
+ def buffer_count
75
+ @buffer.count
76
+ end
77
+
78
+ def dropped_count
79
+ @buffer.dropped_count
80
+ end
81
+
82
+ def captured_count
83
+ @captured_count
84
+ end
85
+
72
86
  private
73
87
 
74
88
  def flush_batch(items)
@@ -23,6 +23,8 @@ module AllStak
23
23
  # Optional callable returning the active release-health session id, so
24
24
  # the backend's error consumer can mark the session errored/crashed.
25
25
  @session_id_provider = session_id_provider
26
+ @captured_count = 0
27
+ @dropped_count = 0
26
28
  end
27
29
 
28
30
  def set_user(id: nil, email: nil, ip: nil)
@@ -58,8 +60,12 @@ module AllStak
58
60
  end
59
61
 
60
62
  def capture_exception(exc, level: "error", user: nil, request_context: nil, trace_id: nil, metadata: nil)
61
- return nil if @transport.disabled?
63
+ if @transport.disabled?
64
+ @dropped_count += 1
65
+ return nil
66
+ end
62
67
  begin
68
+ @captured_count += 1
63
69
  crumbs = drain_breadcrumbs
64
70
 
65
71
  payload = {
@@ -90,9 +96,15 @@ module AllStak
90
96
  # Sampling first, then pre-hook scrub, before_send, and final
91
97
  # transport scrub. Hooks never see raw secrets and cannot reintroduce
92
98
  # values that escape the wire-path sanitizer.
93
- return nil unless Sampling.sampled?(@config.sample_rate)
99
+ unless Sampling.sampled?(@config.sample_rate)
100
+ @dropped_count += 1
101
+ return nil
102
+ end
94
103
  payload = apply_before_send(payload)
95
- return nil if payload.nil?
104
+ if payload.nil?
105
+ @dropped_count += 1
106
+ return nil
107
+ end
96
108
 
97
109
  status, body = @transport.post(PATH, payload)
98
110
  return nil unless status == 202
@@ -114,8 +126,12 @@ module AllStak
114
126
  end
115
127
 
116
128
  def capture_error(exception_class, message, stack_trace: nil, level: "error", user: nil, request_context: nil, trace_id: nil, metadata: nil)
117
- return nil if @transport.disabled?
129
+ if @transport.disabled?
130
+ @dropped_count += 1
131
+ return nil
132
+ end
118
133
  begin
134
+ @captured_count += 1
119
135
  payload = {
120
136
  exceptionClass: exception_class,
121
137
  message: message,
@@ -134,9 +150,15 @@ module AllStak
134
150
 
135
151
  # Sampling first, then pre-hook scrub, before_send, and final
136
152
  # transport scrub.
137
- return nil unless Sampling.sampled?(@config.sample_rate)
153
+ unless Sampling.sampled?(@config.sample_rate)
154
+ @dropped_count += 1
155
+ return nil
156
+ end
138
157
  payload = apply_before_send(payload)
139
- return nil if payload.nil?
158
+ if payload.nil?
159
+ @dropped_count += 1
160
+ return nil
161
+ end
140
162
 
141
163
  status, _ = @transport.post(PATH, payload)
142
164
  status == 202 ? exception_class : nil
@@ -152,6 +174,21 @@ module AllStak
152
174
  end
153
175
  end
154
176
 
177
+ def breadcrumb_count
178
+ buffer = Thread.current[BREADCRUMB_TLS_KEY]
179
+ buffer.respond_to?(:length) ? buffer.length : 0
180
+ rescue StandardError
181
+ 0
182
+ end
183
+
184
+ def captured_count
185
+ @captured_count
186
+ end
187
+
188
+ def dropped_count
189
+ @dropped_count
190
+ end
191
+
155
192
  private
156
193
 
157
194
  # The current thread's breadcrumb ring buffer (lazily created).
@@ -12,6 +12,7 @@ module AllStak
12
12
  @transport = transport
13
13
  @config = config
14
14
  @logger = logger
15
+ @captured_count = 0
15
16
  @buffer = Transport::FlushBuffer.new(
16
17
  name: "http",
17
18
  max_size: config.buffer_size,
@@ -45,6 +46,7 @@ module AllStak
45
46
  environment: @config.environment,
46
47
  release: @config.release
47
48
  }.compact
49
+ @captured_count += 1
48
50
  @buffer.push(item)
49
51
  end
50
52
 
@@ -56,6 +58,18 @@ module AllStak
56
58
  @buffer.shutdown
57
59
  end
58
60
 
61
+ def buffer_count
62
+ @buffer.count
63
+ end
64
+
65
+ def dropped_count
66
+ @buffer.dropped_count
67
+ end
68
+
69
+ def captured_count
70
+ @captured_count
71
+ end
72
+
59
73
  private
60
74
 
61
75
  def strip_query(path)
@@ -13,6 +13,7 @@ module AllStak
13
13
  # entries also surface as breadcrumbs on the next captured exception.
14
14
  # Injected by the client; nil keeps Logs standalone (and recursion-free).
15
15
  @breadcrumb_sink = breadcrumb_sink
16
+ @captured_count = 0
16
17
  @buffer = Transport::FlushBuffer.new(
17
18
  name: "logs",
18
19
  max_size: config.buffer_size,
@@ -40,6 +41,7 @@ module AllStak
40
41
  errorId: error_id,
41
42
  metadata: @config.release_tags.merge(metadata || {})
42
43
  }.compact
44
+ @captured_count += 1
43
45
  @buffer.push(payload)
44
46
  emit_breadcrumb(level, message, trace_id: trace_id, span_id: span_id)
45
47
  nil
@@ -59,6 +61,18 @@ module AllStak
59
61
  @buffer.shutdown
60
62
  end
61
63
 
64
+ def buffer_count
65
+ @buffer.count
66
+ end
67
+
68
+ def dropped_count
69
+ @buffer.dropped_count
70
+ end
71
+
72
+ def captured_count
73
+ @captured_count
74
+ end
75
+
62
76
  private
63
77
 
64
78
  # Bridge an accepted log into a breadcrumb via the injected sink. The
@@ -1,4 +1,5 @@
1
1
  require "securerandom"
2
+ require_relative "../propagation"
2
3
  require_relative "../sampling"
3
4
 
4
5
  module AllStak
@@ -12,6 +13,8 @@ module AllStak
12
13
  @transport = transport
13
14
  @config = config
14
15
  @logger = logger
16
+ @captured_count = 0
17
+ @dropped_count = 0
15
18
  @buffer = Transport::FlushBuffer.new(
16
19
  name: "tracing",
17
20
  max_size: config.buffer_size,
@@ -26,7 +29,12 @@ module AllStak
26
29
  end
27
30
 
28
31
  def set_trace_id(trace_id)
29
- Thread.current[:allstak_trace_id] = trace_id
32
+ Thread.current[:allstak_trace_id] = AllStak::Propagation.normalize_trace_id(trace_id)
33
+ end
34
+
35
+ def set_parent_span_id(span_id)
36
+ return unless AllStak::Propagation.valid_span_id?(span_id)
37
+ Thread.current[:allstak_span_stack] = [span_id.to_s.downcase]
30
38
  end
31
39
 
32
40
  def current_span_id
@@ -101,6 +109,31 @@ module AllStak
101
109
  @buffer.shutdown
102
110
  end
103
111
 
112
+ def buffer_count
113
+ @buffer.count
114
+ end
115
+
116
+ def dropped_count
117
+ @dropped_count + @buffer.dropped_count
118
+ end
119
+
120
+ def captured_count
121
+ @captured_count
122
+ end
123
+
124
+ def active_span_count
125
+ stack = Thread.current[:allstak_span_stack]
126
+ stack.respond_to?(:length) ? stack.length : 0
127
+ rescue StandardError
128
+ 0
129
+ end
130
+
131
+ def active_trace_count
132
+ Thread.current[:allstak_trace_id].nil? ? 0 : 1
133
+ rescue StandardError
134
+ 0
135
+ end
136
+
104
137
  private
105
138
 
106
139
  def on_span_finish(span)
@@ -109,7 +142,11 @@ module AllStak
109
142
  # Drop unsampled spans: they were never meant to be sent. The span
110
143
  # still ran (timing/finish semantics intact) so block-form `in_span`
111
144
  # control flow is unaffected.
112
- return unless span.sampled?
145
+ unless span.sampled?
146
+ @dropped_count += 1
147
+ return
148
+ end
149
+ @captured_count += 1
113
150
  @buffer.push(span.to_h)
114
151
  end
115
152
 
@@ -1,7 +1,14 @@
1
+ require "securerandom"
2
+
1
3
  module AllStak
2
4
  module Propagation
3
5
  module_function
4
6
 
7
+ TRACE_ID_RE = /\A[0-9a-f]{32}\z/.freeze
8
+ SPAN_ID_RE = /\A[0-9a-f]{16}\z/.freeze
9
+ ZERO_TRACE_ID_RE = /\A0{32}\z/.freeze
10
+ ZERO_SPAN_ID_RE = /\A0{16}\z/.freeze
11
+
5
12
  def baggage(trace_id:, request_id: nil, span_id: nil)
6
13
  parts = ["allstak-trace_id=#{trace_id}"]
7
14
  parts << "allstak-request_id=#{request_id}" if request_id && !request_id.to_s.empty?
@@ -23,26 +30,77 @@ module AllStak
23
30
  sampled == false ? "00" : "01"
24
31
  end
25
32
 
33
+ def valid_trace_id?(trace_id)
34
+ value = trace_id.to_s.downcase
35
+ value.match?(TRACE_ID_RE) && !value.match?(ZERO_TRACE_ID_RE)
36
+ end
37
+
38
+ def valid_span_id?(span_id)
39
+ value = span_id.to_s.downcase
40
+ value.match?(SPAN_ID_RE) && !value.match?(ZERO_SPAN_ID_RE)
41
+ end
42
+
43
+ def normalize_trace_id(trace_id)
44
+ hex = trace_id.to_s.gsub(/[^0-9a-f]/i, "").downcase
45
+ candidate =
46
+ if hex.length >= 32
47
+ hex[0, 32]
48
+ elsif !hex.empty?
49
+ hex.ljust(32, "0")
50
+ end
51
+ return candidate if candidate && valid_trace_id?(candidate)
52
+ SecureRandom.hex(16)
53
+ end
54
+
55
+ def normalize_span_id(span_id)
56
+ hex = span_id.to_s.gsub(/[^0-9a-f]/i, "").downcase
57
+ candidate =
58
+ if hex.length >= 16
59
+ hex[0, 16]
60
+ elsif !hex.empty?
61
+ hex.ljust(16, "0")
62
+ end
63
+ return candidate if candidate && valid_span_id?(candidate)
64
+ SecureRandom.hex(8)
65
+ end
66
+
67
+ def parse_traceparent(header)
68
+ match = /\A00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\z/i.match(header.to_s.strip)
69
+ return nil unless match
70
+ trace_id = match[1].downcase
71
+ parent_span_id = match[2].downcase
72
+ return nil unless valid_trace_id?(trace_id) && valid_span_id?(parent_span_id)
73
+ {
74
+ trace_id: trace_id,
75
+ parent_span_id: parent_span_id,
76
+ sampled: (match[3].to_i(16) & 1) == 1
77
+ }
78
+ end
79
+
26
80
  def apply_headers(headers, trace_id:, request_id: nil, span_id: nil, sampled: true)
27
- headers["X-AllStak-Trace-Id"] = trace_id
81
+ wire_trace_id = normalize_trace_id(trace_id)
82
+ wire_span_id = span_id && !span_id.to_s.empty? ? normalize_span_id(span_id) : nil
83
+ headers["X-AllStak-Trace-Id"] = wire_trace_id
28
84
  headers["X-AllStak-Request-Id"] = request_id if request_id && !request_id.to_s.empty?
29
- if span_id && !span_id.to_s.empty?
30
- headers["X-AllStak-Span-Id"] = span_id
31
- headers["traceparent"] = "00-#{trace_id}-#{span_id[0, 16]}-#{trace_flags(sampled)}"
85
+ if wire_span_id
86
+ headers["X-AllStak-Span-Id"] = wire_span_id
87
+ headers["traceparent"] = "00-#{wire_trace_id}-#{wire_span_id}-#{trace_flags(sampled)}"
32
88
  end
33
- headers["baggage"] = merge_baggage(headers["baggage"], trace_id: trace_id, request_id: request_id, span_id: span_id)
34
- headers["AllStak-Baggage"] = baggage(trace_id: trace_id, request_id: request_id, span_id: span_id)
89
+ headers["baggage"] = merge_baggage(headers["baggage"], trace_id: wire_trace_id, request_id: request_id, span_id: wire_span_id)
90
+ headers["AllStak-Baggage"] = baggage(trace_id: wire_trace_id, request_id: request_id, span_id: wire_span_id)
35
91
  end
36
92
 
37
93
  def apply_request_headers(req, trace_id:, request_id: nil, span_id: nil, sampled: true)
38
- req["X-AllStak-Trace-Id"] ||= trace_id
94
+ wire_trace_id = normalize_trace_id(trace_id)
95
+ wire_span_id = span_id && !span_id.to_s.empty? ? normalize_span_id(span_id) : nil
96
+ req["X-AllStak-Trace-Id"] ||= wire_trace_id
39
97
  req["X-AllStak-Request-Id"] ||= request_id if request_id && !request_id.to_s.empty?
40
- if span_id && !span_id.to_s.empty?
41
- req["X-AllStak-Span-Id"] ||= span_id
42
- req["traceparent"] ||= "00-#{trace_id}-#{span_id[0, 16]}-#{trace_flags(sampled)}"
98
+ if wire_span_id
99
+ req["X-AllStak-Span-Id"] ||= wire_span_id
100
+ req["traceparent"] ||= "00-#{wire_trace_id}-#{wire_span_id}-#{trace_flags(sampled)}"
43
101
  end
44
- req["baggage"] = merge_baggage(req["baggage"], trace_id: trace_id, request_id: request_id, span_id: span_id)
45
- req["AllStak-Baggage"] = baggage(trace_id: trace_id, request_id: request_id, span_id: span_id)
102
+ req["baggage"] = merge_baggage(req["baggage"], trace_id: wire_trace_id, request_id: request_id, span_id: wire_span_id)
103
+ req["AllStak-Baggage"] = baggage(trace_id: wire_trace_id, request_id: request_id, span_id: wire_span_id)
46
104
  end
47
105
  end
48
106
  end
@@ -33,6 +33,7 @@
33
33
  # falls back to the key-redacted-but-not-value-scrubbed structure.
34
34
 
35
35
  require "set"
36
+ require "thread"
36
37
 
37
38
  module AllStak
38
39
  module Sanitizer
@@ -166,6 +167,28 @@ module AllStak
166
167
 
167
168
  module_function
168
169
 
170
+ @redaction_count = 0
171
+ @redaction_mutex = Mutex.new
172
+
173
+ def redaction_count
174
+ @redaction_mutex.synchronize { @redaction_count }
175
+ rescue StandardError
176
+ 0
177
+ end
178
+
179
+ def reset_redaction_count!
180
+ @redaction_mutex.synchronize { @redaction_count = 0 }
181
+ nil
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ def record_redaction
187
+ @redaction_mutex.synchronize { @redaction_count += 1 }
188
+ rescue StandardError
189
+ nil
190
+ end
191
+
169
192
  # Returns a sanitized deep copy of `payload`.
170
193
  #
171
194
  # @param extra_denylist [Array<String>, nil] additional key terms to redact;
@@ -207,6 +230,7 @@ module AllStak
207
230
  value.each_with_object({}) do |(k, v), out|
208
231
  out[k] =
209
232
  if sensitive?(k, denylist)
233
+ record_redaction
210
234
  REDACTED
211
235
  elsif skip_subtree?(k)
212
236
  # Explicit user object / stack frames: deep-copy with key-name
@@ -241,7 +265,12 @@ module AllStak
241
265
 
242
266
  seen.add(value.object_id)
243
267
  value.each_with_object({}) do |(k, v), out|
244
- out[k] = sensitive?(k, denylist) ? REDACTED : walk_keys_only(v, denylist, seen)
268
+ if sensitive?(k, denylist)
269
+ record_redaction
270
+ out[k] = REDACTED
271
+ else
272
+ out[k] = walk_keys_only(v, denylist, seen)
273
+ end
245
274
  end
246
275
  when Array
247
276
  return REDACTED if seen.include?(value.object_id)
@@ -282,6 +311,7 @@ module AllStak
282
311
  out = out.gsub(IPV6_REGEX, REDACTED)
283
312
  end
284
313
 
314
+ record_redaction if out != str
285
315
  out
286
316
  rescue StandardError
287
317
  str
@@ -107,6 +107,10 @@ module AllStak
107
107
  @mutex.synchronize { (@started && !@ended) ? @session_id : nil }
108
108
  end
109
109
 
110
+ def recovery_count
111
+ 0
112
+ end
113
+
110
114
  # Record a HANDLED error: bump status ok -> errored (never downgrades a
111
115
  # terminal crash). No I/O.
112
116
  def record_error
@@ -21,6 +21,7 @@ module AllStak
21
21
  @queue = []
22
22
  @stopped = false
23
23
  @overflow_warned = false
24
+ @dropped_count = 0
24
25
  @flushing_mutex = Mutex.new
25
26
  start_timer
26
27
  end
@@ -29,6 +30,7 @@ module AllStak
29
30
  synchronize do
30
31
  if @queue.length >= @max_size
31
32
  @queue.shift
33
+ @dropped_count += 1
32
34
  unless @overflow_warned
33
35
  @overflow_warned = true
34
36
  @logger.warn("[AllStak] Buffer #{@name} full (#{@max_size}); oldest events dropped")
@@ -45,6 +47,10 @@ module AllStak
45
47
  synchronize { @queue.length }
46
48
  end
47
49
 
50
+ def dropped_count
51
+ synchronize { @dropped_count }
52
+ end
53
+
48
54
  def flush
49
55
  @flushing_mutex.synchronize do
50
56
  drained = synchronize do
@@ -1,6 +1,8 @@
1
1
  require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
+ require "thread"
5
+ require "zlib"
4
6
  require_relative "../sanitizer"
5
7
  require_relative "event_spool"
6
8
 
@@ -16,7 +18,7 @@ module AllStak
16
18
  # backoff = 1s → 2s → 4s → 8s (+ jitter 0-500ms)
17
19
  # max attempts = 5
18
20
  # 401 → disable SDK
19
- # 4xx (400/403/404/422) → no retry
21
+ # 4xx except 429 → no retry
20
22
  # 5xx / network → retry
21
23
  class HttpTransport
22
24
  NON_RETRYABLE_STATUSES = [400, 401, 403, 404, 422].freeze
@@ -25,6 +27,7 @@ module AllStak
25
27
  RETRY_AFTER_STATUSES = [429, 503].freeze
26
28
  # Upper bound on any honored Retry-After delay, in seconds.
27
29
  MAX_RETRY_AFTER = 300.0
30
+ COMPRESSION_THRESHOLD_BYTES = 1024
28
31
 
29
32
  # Session lifecycle calls are best-effort LIVE-only — a replayed stale
30
33
  # session would skew durations, so they are NEVER spooled to disk.
@@ -42,6 +45,17 @@ module AllStak
42
45
  @api_key = config.api_key
43
46
  @disabled = false
44
47
  @spool = build_spool(config, logger)
48
+ @stats_mutex = Mutex.new
49
+ @sent_count = 0
50
+ @failed_count = 0
51
+ @dropped_count = 0
52
+ @persisted_count = 0
53
+ @replayed_count = 0
54
+ @retry_attempt_count = 0
55
+ @rate_limited_count = 0
56
+ @compressed_count = 0
57
+ @uncompressed_count = 0
58
+ @compression_bytes_saved = 0
45
59
  end
46
60
 
47
61
  # The offline spool, or nil when disabled / unavailable. Exposed for
@@ -68,11 +82,15 @@ module AllStak
68
82
  AllStak::Sanitizer.scrub(parsed, **scrub_options)
69
83
  rescue StandardError => e
70
84
  @logger.debug("[AllStak] spool scrub failed; not persisting: #{e.class}: #{e.message}") if @config.debug
85
+ increment(:@dropped_count)
71
86
  return false
72
87
  end
73
- @spool.persist(path, scrubbed)
88
+ persisted = @spool.persist(path, scrubbed)
89
+ increment(persisted ? :@persisted_count : :@dropped_count)
90
+ persisted
74
91
  rescue StandardError => e
75
92
  @logger.debug("[AllStak] persist_failed swallowed: #{e.class}: #{e.message}") if @config.debug
93
+ increment(:@dropped_count)
76
94
  false
77
95
  end
78
96
 
@@ -89,6 +107,8 @@ module AllStak
89
107
  status, _ = post(path, payload)
90
108
  # post() returns for 2xx and non-retryable 4xx; raises otherwise.
91
109
  if status && (status < 400 || (status >= 400 && status != 429))
110
+ increment(:@replayed_count) if status < 400
111
+ increment(:@dropped_count) if status >= 400
92
112
  @spool.remove(handle)
93
113
  end
94
114
  rescue AllStakAuthError
@@ -113,9 +133,26 @@ module AllStak
113
133
  end
114
134
 
115
135
  def post(path, payload)
116
- raise AllStakAuthError, "SDK disabled" if @disabled
136
+ if @disabled
137
+ increment(:@dropped_count)
138
+ raise AllStakAuthError, "SDK disabled"
139
+ end
117
140
 
118
- wire_payload = serialize_payload(payload)
141
+ wire_payload =
142
+ begin
143
+ serialize_payload(payload)
144
+ rescue AllStakTransportError
145
+ increment(:@failed_count)
146
+ increment(:@dropped_count)
147
+ raise
148
+ end
149
+ wire_body, compressed, bytes_saved = prepare_body(wire_payload)
150
+ if compressed
151
+ increment(:@compressed_count)
152
+ increment(:@compression_bytes_saved, bytes_saved)
153
+ else
154
+ increment(:@uncompressed_count)
155
+ end
119
156
 
120
157
  uri = URI.parse("#{@base_url}#{path}")
121
158
  http = Net::HTTP.new(uri.host, uri.port)
@@ -136,7 +173,8 @@ module AllStak
136
173
  "X-AllStak-Key" => @api_key,
137
174
  "User-Agent" => "allstak-ruby/#{AllStak::VERSION}"
138
175
  })
139
- req.body = wire_payload
176
+ req["Content-Encoding"] = "gzip" if compressed
177
+ req.body = wire_body
140
178
  @logger.debug("[AllStak] POST #{path} attempt=#{attempt}") if @config.debug
141
179
 
142
180
  resp = http.request(req)
@@ -145,15 +183,25 @@ module AllStak
145
183
 
146
184
  if last_status == 401
147
185
  @disabled = true
186
+ increment(:@failed_count)
187
+ increment(:@dropped_count)
148
188
  @logger.warn("[AllStak] SDK disabled: invalid API key (401). No further events will be sent.")
149
189
  raise AllStakAuthError, "Invalid API key"
150
190
  end
151
191
 
152
- return [last_status, body] if NON_RETRYABLE_STATUSES.include?(last_status)
153
- return [last_status, body] if last_status < 400
192
+ if NON_RETRYABLE_STATUSES.include?(last_status) ||
193
+ (last_status >= 400 && last_status < 500 && last_status != 429)
194
+ increment(:@dropped_count) if last_status >= 400
195
+ return [last_status, body]
196
+ end
197
+ if last_status < 400
198
+ increment(:@sent_count)
199
+ return [last_status, body]
200
+ end
154
201
 
155
202
  # 429 (rate limited) / 503 (unavailable) → honor Retry-After when present.
156
203
  if RETRY_AFTER_STATUSES.include?(last_status)
204
+ increment(:@rate_limited_count) if last_status == 429
157
205
  parsed = parse_retry_after(resp["Retry-After"])
158
206
  retry_after_delay = parsed if parsed > 0
159
207
  end
@@ -171,6 +219,7 @@ module AllStak
171
219
  end
172
220
 
173
221
  if attempt < max_attempts
222
+ increment(:@retry_attempt_count)
174
223
  if retry_after_delay
175
224
  # Server told us how long to wait; honor it (already clamped).
176
225
  sleep(retry_after_delay)
@@ -182,10 +231,38 @@ module AllStak
182
231
  end
183
232
  end
184
233
 
234
+ increment(:@failed_count)
185
235
  raise AllStakTransportError,
186
236
  "All #{max_attempts} attempts failed for POST #{path}. last_status=#{last_status} last_error=#{last_exc&.message}"
187
237
  end
188
238
 
239
+ def diagnostics
240
+ snapshot = @stats_mutex.synchronize do
241
+ {
242
+ sent: @sent_count,
243
+ failed: @failed_count,
244
+ dropped: @dropped_count,
245
+ persisted: @persisted_count,
246
+ replayed: @replayed_count,
247
+ retry_attempts: @retry_attempt_count,
248
+ rate_limited: @rate_limited_count,
249
+ compressed: @compressed_count,
250
+ uncompressed: @uncompressed_count,
251
+ compression_bytes_saved: @compression_bytes_saved
252
+ }
253
+ end
254
+ snapshot[:queue_size] = @spool&.size.to_i
255
+ snapshot[:disabled] = disabled?
256
+ snapshot
257
+ rescue StandardError
258
+ {
259
+ sent: 0, failed: 0, dropped: 0, persisted: 0, replayed: 0,
260
+ retry_attempts: 0, rate_limited: 0, queue_size: 0,
261
+ compressed: 0, uncompressed: 0, compression_bytes_saved: 0,
262
+ disabled: disabled?
263
+ }
264
+ end
265
+
189
266
  def serialize_payload(payload)
190
267
  parsed = payload.is_a?(String) ? JSON.parse(payload) : payload
191
268
  JSON.generate(AllStak::Sanitizer.scrub(parsed, **scrub_options))
@@ -238,6 +315,26 @@ module AllStak
238
315
 
239
316
  private
240
317
 
318
+ def prepare_body(wire_payload)
319
+ return [wire_payload, false, 0] if wire_payload.bytesize < COMPRESSION_THRESHOLD_BYTES
320
+
321
+ compressed = Zlib.gzip(wire_payload)
322
+ return [wire_payload, false, 0] if compressed.bytesize >= wire_payload.bytesize
323
+
324
+ [compressed, true, wire_payload.bytesize - compressed.bytesize]
325
+ rescue StandardError
326
+ [wire_payload, false, 0]
327
+ end
328
+
329
+ def increment(counter, by = 1)
330
+ @stats_mutex.synchronize do
331
+ value = instance_variable_get(counter).to_i
332
+ instance_variable_set(counter, value + by.to_i)
333
+ end
334
+ rescue StandardError
335
+ nil
336
+ end
337
+
241
338
  # Build the offline spool from config, or nil when opted out. The spool
242
339
  # itself fails open (an unwritable dir leaves it `available? == false`),
243
340
  # so a non-nil-but-unavailable spool is fine — every public method guards
@@ -1,3 +1,3 @@
1
1
  module AllStak
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
data/lib/allstak.rb CHANGED
@@ -3,6 +3,7 @@ require "time"
3
3
 
4
4
  require_relative "allstak/version"
5
5
  require_relative "allstak/config"
6
+ require_relative "allstak/diagnostics"
6
7
  require_relative "allstak/transport/http_transport"
7
8
  require_relative "allstak/transport/flush_buffer"
8
9
  require_relative "allstak/models/user_context"
@@ -228,6 +229,14 @@ module AllStak
228
229
  @client&.shutdown
229
230
  end
230
231
 
232
+ # Privacy-safe SDK diagnostics for the active client. Returns a disabled
233
+ # zero snapshot before configure or when configuration is invalid.
234
+ def get_diagnostics
235
+ @client&.diagnostics || Diagnostics.new(disabled: true)
236
+ end
237
+
238
+ alias diagnostics get_diagnostics
239
+
231
240
  # Test helper.
232
241
  def reset!
233
242
  @mutex.synchronize do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: allstak
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - AllStak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-30 00:00:00.000000000 Z
11
+ date: 2026-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -68,6 +68,7 @@ files:
68
68
  - lib/allstak.rb
69
69
  - lib/allstak/client.rb
70
70
  - lib/allstak/config.rb
71
+ - lib/allstak/diagnostics.rb
71
72
  - lib/allstak/global_handler.rb
72
73
  - lib/allstak/integrations/active_record.rb
73
74
  - lib/allstak/integrations/logger.rb