allstak 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2487acc9b1a4a0491b0a86d246b62ffcdb3a02499d728c8eaf01af50c9779d3
4
- data.tar.gz: 411db967c468cb7c1793ade463b314cb44169b19071a3ed1d90fdf5bc3a582dc
3
+ metadata.gz: ea16fc5869d09516a29301382b4d59d9e3f33018226516beb208aa1c826a77f3
4
+ data.tar.gz: 6b73ffb1e1319585c2be4c7398181cf9beae32a4ea17914501df6ae1a5df2a66
5
5
  SHA512:
6
- metadata.gz: 9c91a85d0c9208021a2ed16ad0c3fa9a8d701b313d3dcd7e127b3d3a87c7d454cdd007dc65e948dec85fdf2f1a4cffaa4fc62367d13a23553df7cb7770566f55
7
- data.tar.gz: 6e597806d72c3da0692ff4c14eea6aef1a09d7c6c83bbc917015fa925e9e55e82e9eebc5e91b6a9c232f0c40ce64f4b603870da485ec8526876710b34f0f938d
6
+ metadata.gz: 1a21a2da36cf561691221369b9cf25fdfc4063300fc3f32452652d6f0987044b9121d719a597db9d6b18d617583e0253e8ee59f08539c8f2ed3c1efe5ffd543a
7
+ data.tar.gz: 43e2c1d26dd502e77fe498e4850352235a63be28fa33ac5c2d61a4ee7171cafda55bc11285d781de96664cdb962becf4df387c83573ccdfa495de3e80fe05695
data/CHANGELOG.md CHANGED
@@ -5,11 +5,43 @@ This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## [0.3.0] — 2026-05-30
9
+
10
+ ### Added — Automatic breadcrumbs across all instrumentation
11
+ - Breadcrumbs are now collected automatically from every auto-instrumentation
12
+ layer, not just Sidekiq. The inbound Rack middleware (request method/path/
13
+ status/duration), the outbound `Net::HTTP` patch (method/host/path/status/
14
+ duration), and the ActiveRecord subscriber (truncated SQL/duration/status)
15
+ each emit a breadcrumb, and every `AllStak.log.*` call is bridged into a
16
+ `log` breadcrumb. The trail is attached to the next captured exception with
17
+ no per-call developer code.
18
+ - The breadcrumb ring buffer is now **per-thread** (50 entries), so one
19
+ request/job's trail never leaks into a concurrent request's captured
20
+ exception. Breadcrumb collection is drained on capture as before.
21
+ - New `config.enable_auto_breadcrumbs` (default `true`; env
22
+ `ALLSTAK_AUTO_BREADCRUMBS=0/false` to disable) gates only the automatic
23
+ layers. New top-level `AllStak.add_breadcrumb(...)` records a manual
24
+ breadcrumb and always works regardless of the toggle.
25
+
26
+ ### Added — Optional structured-log adapter
27
+ - `AllStak::Integrations::Logger` — a `::Logger`-compatible sink that forwards
28
+ records to `/ingest/v1/logs` via `AllStak.log`. Compose it alongside your
29
+ existing logger (`AllStak::Integrations::Logger.attach_to_rails!` on Rails
30
+ 7.1+ BroadcastLogger, or `AllStak::Integrations::Logger.broadcast(logger)`)
31
+ so existing log destinations are preserved and app logs ship automatically.
32
+ - ERROR PROMOTION: records at/above `ERROR` (configurable via
33
+ `error_promotion_level:`) are additionally captured as message error-group
34
+ entries so they surface in the Errors list. Opt out with
35
+ `error_promotion: false`. Opt-in by design and fully fail-open — the adapter
36
+ never raises into the host's logging path and is a no-op until the SDK is
37
+ configured. Defined on require but never auto-attached, so default logging
38
+ behavior is unchanged.
39
+
8
40
  ## [0.2.0] — 2026-05-29
9
41
 
10
42
  ### Added — Release-health session tracking
11
43
  - `AllStak::SessionTracker` — server-mode single-session release-health
12
- lifecycle that mirrors the AllStak Java SDK. On boot it POSTs
44
+ lifecycle that mirrors the AllStak SDKs. On boot it POSTs
13
45
  `/ingest/v1/sessions/start` (off the hot path, on a daemon thread) with a
14
46
  per-process `sessionId`, the resolved release, environment, and SDK identity;
15
47
  on shutdown it POSTs `/ingest/v1/sessions/end` (synchronous, best-effort)
@@ -46,11 +78,11 @@ This project follows [Semantic Versioning](https://semver.org/).
46
78
  ### Added — Value-pattern PII scrubbing + sendDefaultPii
47
79
  - `AllStak::Sanitizer` gains a second scrubbing layer alongside the existing
48
80
  key-name denylist: VALUE-PATTERN redaction that scans free-text string values
49
- for PII regardless of key name (Sentry data-scrubbing parity). Tier A is
81
+ for PII regardless of key name (AllStak data-scrubbing). Tier A is
50
82
  ALWAYS scrubbed — Luhn-validated credit-card numbers (13–19 digits, optional
51
83
  space/hyphen separators) and hyphenated US SSNs. Tier B — email addresses and
52
84
  IPv4/IPv6 — is scrubbed UNLESS `send_default_pii` is enabled.
53
- - `config.send_default_pii` (default `false`, Sentry parity; env
85
+ - `config.send_default_pii` (default `false`, ; env
54
86
  `ALLSTAK_SEND_DEFAULT_PII`) opts into shipping email/IP values. The
55
87
  always-on financial/identity scrubbers are not affected by this flag.
56
88
  - Structural exemptions prevent over-redaction: the explicit `user` object and
@@ -128,8 +160,8 @@ This project follows [Semantic Versioning](https://semver.org/).
128
160
  logs at Warning level and sends raw — telemetry is never blocked.
129
161
 
130
162
  ### Live canary E2E
131
- - Event `03188061-8054-439a-8784-a3b52436549e` against `api.allstak.sa`.
132
- - ClickHouse confirmed `leak_pos = 0` across `metadata`, `stack_trace`,
163
+ - Verified end-to-end against the AllStak ingest API at `api.allstak.sa`.
164
+ - Ingested event confirmed `leak_pos = 0` across `metadata`, `stack_trace`,
133
165
  `breadcrumbs`, `message`. Canary `should_not_leak_ruby` planted in
134
166
  `password`, `authorization`, `cookie`, `Bearer`, `api_key`, request
135
167
  headers / body, `credit_card`, `ssn`, and a 3-level-nested `token`.
data/README.md CHANGED
@@ -76,6 +76,50 @@ AllStak.tracing.in_span("checkout.authorize") do
76
76
  end
77
77
  ```
78
78
 
79
+ ## Breadcrumbs
80
+
81
+ Breadcrumbs are collected **automatically** — no per-call code. After
82
+ `AllStak.configure`, the SDK records a trail of recent events on a 50-entry
83
+ **per-thread** ring buffer and attaches it to the next captured exception on
84
+ that thread. Auto breadcrumbs come from:
85
+
86
+ - inbound Rack requests (method, path, status, duration),
87
+ - outbound `Net::HTTP` calls (method, host, path, status, duration),
88
+ - ActiveRecord queries (truncated SQL, duration, status),
89
+ - Sidekiq job processing,
90
+ - and every `AllStak.log.*` call.
91
+
92
+ Because the buffer is per-thread, one request's trail never bleeds into a
93
+ concurrent request's exception. You can also add your own:
94
+
95
+ ```ruby
96
+ AllStak.add_breadcrumb(type: "auth", message: "password reset requested",
97
+ data: { "userId" => current_user.id })
98
+ ```
99
+
100
+ Disable automatic collection (manual `add_breadcrumb` still works) with
101
+ `enable_auto_breadcrumbs = false` or `ALLSTAK_AUTO_BREADCRUMBS=0`.
102
+
103
+ ## Structured logs (optional)
104
+
105
+ Ship your application logs to AllStak by composing the optional log adapter
106
+ with your existing logger — existing log destinations are preserved:
107
+
108
+ ```ruby
109
+ # Rails 7.1+ (broadcast alongside the current logger):
110
+ AllStak::Integrations::Logger.attach_to_rails!
111
+
112
+ # Any Ruby logger:
113
+ logger = AllStak::Integrations::Logger.broadcast(logger)
114
+ ```
115
+
116
+ Once attached, `logger.info / .warn / .error` ship to `/ingest/v1/logs`
117
+ automatically. Records at `ERROR`/`FATAL` are additionally promoted to error
118
+ entries so they surface in the Errors list. Disable promotion with
119
+ `AllStak::Integrations::Logger.new(error_promotion: false)`, or change the
120
+ threshold with `error_promotion_level:`. The adapter is opt-in and fail-open —
121
+ it never raises into your logging path.
122
+
79
123
  ## Configuration
80
124
 
81
125
  | Option | Description |
@@ -86,6 +130,7 @@ end
86
130
  | `service_name` | Logical service name. |
87
131
  | `flush_interval_ms` | Background flush interval. |
88
132
  | `buffer_size` | Max buffered events. |
133
+ | `enable_auto_breadcrumbs` | Collect breadcrumbs automatically from the Rack/Net::HTTP/ActiveRecord/Sidekiq instrumentation and the `AllStak.log.*` bridge (default `true`; env `ALLSTAK_AUTO_BREADCRUMBS=0` to disable). Manual `AllStak.add_breadcrumb` is unaffected. |
89
134
  | `install_at_exit_handler` | Install a process-wide `at_exit` hook that captures the exception terminating the process as an unhandled event (default `true`). |
90
135
  | `before_send` | Callable invoked with the event hash just before transport. Return a modified hash, or `nil` to drop the event. Fails open (sends the original) if it raises. |
91
136
  | `sample_rate` | Float in `[0.0, 1.0]` head-sampling rate for error/message events (default `1.0` = keep all). |
@@ -14,7 +14,14 @@ module AllStak
14
14
 
15
15
  @errors = Modules::Errors.new(@transport, config, logger,
16
16
  session_id_provider: -> { @session_tracker&.current_session_id })
17
- @logs = Modules::Logs.new(@transport, config, logger)
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)
18
25
  @http = Modules::HttpMonitor.new(@transport, config, logger)
19
26
  @tracing = Modules::Tracing.new(@transport, config, logger)
20
27
  @database = Modules::Database.new(@transport, config, logger)
@@ -73,15 +73,25 @@ module AllStak
73
73
  :connect_timeout, :read_timeout, :max_retries,
74
74
  :capture_unhandled_exceptions, :capture_http_requests,
75
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,
76
85
  # Install a process-wide `at_exit` hook that captures the
77
86
  # exception terminating the process (global uncaught
78
87
  # handler). Opt out by setting this false.
79
88
  :install_at_exit_handler,
80
89
  # Event pre-processing hook. A callable (proc/lambda) invoked
81
- # once just before transport with the event Hash; returns a
82
- # (possibly modified) event Hash, or nil to DROP the event.
83
- # Runs for exception + message capture. Fail-open: if it
84
- # raises, the original event is sent.
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.
85
95
  :before_send,
86
96
  # Deterministic head-sampling for error/message events in
87
97
  # [0.0, 1.0]. 1.0 keeps everything (default); 0.0 drops all.
@@ -124,7 +134,7 @@ module AllStak
124
134
  :offline_queue_max_entries,
125
135
  :offline_queue_max_bytes,
126
136
  :offline_queue_max_age_s,
127
- # Sentry-parity PII control. When FALSE (default), value-pattern
137
+ # PII control. When FALSE (default), value-pattern
128
138
  # scrubbing strips email + IP addresses from free-text values
129
139
  # before they hit the wire, and any auto-collected client IP
130
140
  # the SDK attaches is dropped. When TRUE, the caller has opted
@@ -197,7 +207,7 @@ module AllStak
197
207
  @offline_queue_max_entries = nil
198
208
  @offline_queue_max_bytes = nil
199
209
  @offline_queue_max_age_s = nil
200
- # Sentry parity: default FALSE. Opt in via ALLSTAK_SEND_DEFAULT_PII.
210
+ # Default FALSE. Opt in via ALLSTAK_SEND_DEFAULT_PII.
201
211
  @send_default_pii = %w[1 true yes on].include?(ENV["ALLSTAK_SEND_DEFAULT_PII"].to_s.strip.downcase)
202
212
  @extra_denylist = parse_extra_denylist(ENV["ALLSTAK_EXTRA_DENYLIST"])
203
213
  @service_name = ENV["ALLSTAK_SERVICE"] || "ruby-service"
@@ -211,6 +221,9 @@ module AllStak
211
221
  @capture_http_requests = true
212
222
  @capture_user_context = true
213
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)
214
227
  @install_at_exit_handler = true
215
228
  @before_send = nil
216
229
  @sample_rate = 1.0
@@ -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
@@ -0,0 +1,201 @@
1
+ require "logger"
2
+
3
+ module AllStak
4
+ module Integrations
5
+ # Optional structured-log adapter.
6
+ #
7
+ # A drop-in {::Logger}-compatible sink that forwards every log record to
8
+ # AllStak's `/ingest/v1/logs` endpoint (via {AllStak.log}). It is intended
9
+ # to be *broadcast alongside* your existing logger so app logs keep going to
10
+ # STDOUT/file unchanged while also flowing into AllStak — no per-call code.
11
+ #
12
+ # ERROR PROMOTION: records at or above {#error_promotion_level} (default
13
+ # ERROR) are additionally captured as AllStak "message" error-group entries
14
+ # (via {AllStak.capture_message}) so error-level logs surface in the Errors
15
+ # list, not just the log stream. Set `error_promotion: false` to disable.
16
+ #
17
+ # OPT-IN by design — adding this adapter is the only manual step; after that
18
+ # `Rails.logger.error(...)` / `logger.warn(...)` ship automatically.
19
+ #
20
+ # # Rails 7.1+ (BroadcastLogger) — keep existing logging, add AllStak:
21
+ # AllStak::Integrations::Logger.attach_to_rails!
22
+ #
23
+ # # Or compose manually with any Ruby Logger:
24
+ # sink = AllStak::Integrations::Logger.new
25
+ # Rails.logger.broadcast_to(sink) # Rails 7.1+
26
+ # # ...or wrap a single logger so both get every line:
27
+ # Rails.logger = AllStak::Integrations::Logger.broadcast(Rails.logger)
28
+ #
29
+ # Fully fail-open: a transport/SDK error never propagates into the host's
30
+ # logging path, and the adapter is a graceful no-op when the SDK is not
31
+ # configured.
32
+ class Logger < ::Logger
33
+ # Map Ruby Logger severities to AllStak log levels.
34
+ SEVERITY_TO_LEVEL = {
35
+ ::Logger::DEBUG => "debug",
36
+ ::Logger::INFO => "info",
37
+ ::Logger::WARN => "warn",
38
+ ::Logger::ERROR => "error",
39
+ ::Logger::FATAL => "fatal",
40
+ ::Logger::UNKNOWN => "error"
41
+ }.freeze
42
+
43
+ attr_reader :error_promotion_level
44
+
45
+ # @param level [Integer] minimum severity to forward (default DEBUG).
46
+ # @param error_promotion [Boolean] capture >= error_promotion_level
47
+ # records as AllStak message events too (default true).
48
+ # @param error_promotion_level [Integer] severity threshold for promotion
49
+ # (default ::Logger::ERROR).
50
+ def initialize(level: ::Logger::DEBUG, error_promotion: true,
51
+ error_promotion_level: ::Logger::ERROR)
52
+ # logdev=nil: this sink does not write to any device of its own; it only
53
+ # forwards into AllStak. Composition (broadcast) keeps the real device.
54
+ super(nil)
55
+ self.level = level
56
+ @error_promotion = error_promotion != false
57
+ @error_promotion_level = error_promotion_level
58
+ end
59
+
60
+ # Core Logger entry point. Every severity helper (#debug/#info/#warn/
61
+ # #error/#fatal/#unknown) and `<<` funnel through #add, so overriding it
62
+ # captures the whole surface. Returns true (Logger#add contract) and never
63
+ # raises into the host.
64
+ def add(severity, message = nil, progname = nil, &block)
65
+ severity ||= ::Logger::UNKNOWN
66
+ return true if severity < level
67
+
68
+ text = resolve_message(message, progname, &block)
69
+ forward(severity, text, progname)
70
+ true
71
+ rescue StandardError
72
+ true
73
+ end
74
+
75
+ # `logger << "raw"` writes an UNKNOWN-severity record in Ruby Logger.
76
+ def <<(msg)
77
+ add(::Logger::UNKNOWN, msg.to_s)
78
+ msg
79
+ end
80
+
81
+ class << self
82
+ # Compose `existing` with an AllStak sink so both receive every record.
83
+ # Prefers Rails' BroadcastLogger when available; otherwise returns a
84
+ # {BroadcastLogger} shim. Returns `existing` unchanged on any failure.
85
+ def broadcast(existing, **opts)
86
+ sink = new(**opts)
87
+ if defined?(::ActiveSupport::BroadcastLogger)
88
+ ::ActiveSupport::BroadcastLogger.new(existing, sink)
89
+ else
90
+ BroadcastLogger.new([existing, sink])
91
+ end
92
+ rescue StandardError
93
+ existing
94
+ end
95
+
96
+ # Attach an AllStak sink to the current Rails.logger without replacing
97
+ # the existing logging destinations. Idempotent and fail-open. Returns
98
+ # true when (now) attached, false otherwise (no Rails / no logger).
99
+ def attach_to_rails!(**opts)
100
+ return false unless defined?(::Rails) && ::Rails.respond_to?(:logger)
101
+ current = ::Rails.logger
102
+ return false if current.nil?
103
+ return true if @attached_to_rails
104
+
105
+ sink = new(**opts)
106
+ if current.respond_to?(:broadcast_to)
107
+ # Rails 7.1+ BroadcastLogger — add without disturbing existing sinks.
108
+ current.broadcast_to(sink)
109
+ else
110
+ ::Rails.logger = broadcast(current, **opts)
111
+ end
112
+ @attached_to_rails = true
113
+ true
114
+ rescue StandardError
115
+ false
116
+ end
117
+
118
+ # Test seam.
119
+ def reset_attached!
120
+ @attached_to_rails = false
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ # Ruby Logger's message-resolution rules: an explicit message wins; else a
127
+ # block's value; else the progname is treated as the message.
128
+ def resolve_message(message, progname, &block)
129
+ if message.nil?
130
+ block ? block.call : progname
131
+ else
132
+ message
133
+ end
134
+ end
135
+
136
+ def forward(severity, text, progname)
137
+ return if text.nil?
138
+ return unless AllStak.respond_to?(:initialized?) && AllStak.initialized?
139
+
140
+ level = SEVERITY_TO_LEVEL.fetch(severity, "info")
141
+ msg = text.to_s
142
+ return if msg.empty?
143
+
144
+ metadata = {}
145
+ metadata["logger"] = progname.to_s unless progname.nil? || progname.to_s.empty?
146
+
147
+ sink = AllStak.log
148
+ sink&.log(level, msg, metadata: metadata.empty? ? nil : metadata)
149
+
150
+ # ERROR PROMOTION: surface error/fatal logs as message error-group
151
+ # entries too, so they appear in the Errors list. Best-effort.
152
+ if @error_promotion && severity >= @error_promotion_level
153
+ AllStak.capture_message(msg, level: level, metadata: metadata.empty? ? nil : metadata)
154
+ end
155
+ rescue StandardError
156
+ nil
157
+ end
158
+
159
+ # Minimal broadcast shim for runtimes without ActiveSupport::BroadcastLogger
160
+ # (plain Ruby, older Rails). Fans every public Logger call out to each
161
+ # delegate; reads (e.g. #level) come from the first. Fail-open per call.
162
+ class BroadcastLogger
163
+ def initialize(loggers)
164
+ @loggers = Array(loggers)
165
+ end
166
+
167
+ %i[debug info warn error fatal unknown add log <<].each do |m|
168
+ define_method(m) do |*args, &block|
169
+ @loggers.each do |l|
170
+ begin
171
+ l.public_send(m, *args, &block) if l.respond_to?(m)
172
+ rescue StandardError
173
+ nil
174
+ end
175
+ end
176
+ true
177
+ end
178
+ end
179
+
180
+ # Read-ish delegations resolve against the first logger.
181
+ def level
182
+ @loggers.first&.level
183
+ end
184
+
185
+ def level=(value)
186
+ @loggers.each { |l| l.level = value if l.respond_to?(:level=) }
187
+ end
188
+
189
+ def respond_to_missing?(name, include_private = false)
190
+ @loggers.any? { |l| l.respond_to?(name, include_private) } || super
191
+ end
192
+
193
+ def method_missing(name, *args, &block)
194
+ target = @loggers.find { |l| l.respond_to?(name) }
195
+ return super unless target
196
+ target.public_send(name, *args, &block)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -70,6 +70,24 @@ module AllStak
70
70
  span_id: span_id,
71
71
  error_fingerprint: error_fp
72
72
  )
73
+
74
+ # Outbound-call breadcrumb so it lands on the trail of any
75
+ # exception captured later in the same thread. Auto-gated.
76
+ client.errors.add_breadcrumb(
77
+ type: "http",
78
+ message: "#{method} #{host}#{path} #{status}",
79
+ level: (error_fp || status >= 500) ? "error" : "info",
80
+ data: {
81
+ "direction" => "outbound",
82
+ "method" => method,
83
+ "host" => host,
84
+ "path" => path,
85
+ "status" => status,
86
+ "durationMs" => duration,
87
+ "error" => error_fp
88
+ }.reject { |_, v| v.nil? },
89
+ auto: true
90
+ )
73
91
  rescue
74
92
  # never raise into host
75
93
  end
@@ -80,6 +80,23 @@ module AllStak
80
80
  )
81
81
  span.set_tag("http.status_code", status.to_i.to_s)
82
82
  span.finish(status.to_i >= 500 || captured ? "error" : "ok")
83
+
84
+ # Inbound-request breadcrumb so it lands on the trail of any
85
+ # exception captured later in the same thread. Auto-gated.
86
+ client.errors.add_breadcrumb(
87
+ type: "http",
88
+ message: "#{env["REQUEST_METHOD"] || "GET"} #{path} #{status.to_i}",
89
+ level: (status.to_i >= 500 || captured) ? "error" : "info",
90
+ data: {
91
+ "direction" => "inbound",
92
+ "method" => env["REQUEST_METHOD"] || "GET",
93
+ "host" => env["HTTP_HOST"] || "localhost",
94
+ "path" => path,
95
+ "status" => status.to_i,
96
+ "durationMs" => duration
97
+ },
98
+ auto: true
99
+ )
83
100
  rescue => err
84
101
  # never raise into host
85
102
  config.debug && warn("[AllStak] rack request capture failed: #{err.message}")
@@ -167,7 +184,7 @@ module AllStak
167
184
  email = env["allstak.user_email"]
168
185
  return nil if id.nil? && email.nil?
169
186
  # The client IP here is AUTO-collected by the middleware (not set
170
- # explicitly by the app via setUser). Sentry parity: drop it unless
187
+ # explicitly by the app via setUser). Privacy default: drop it unless
171
188
  # the caller opted into PII via send_default_pii. Guarded so a nil/old
172
189
  # config defaults to the privacy-preserving behavior.
173
190
  send_pii = config.respond_to?(:send_default_pii?) ? config.send_default_pii? : false
@@ -117,7 +117,8 @@ module AllStak
117
117
  client.errors.add_breadcrumb(
118
118
  type: "sidekiq",
119
119
  message: "process #{worker_class}",
120
- data: { "queue" => job_queue, "jid" => jid }.reject { |_, v| v.nil? }
120
+ data: { "queue" => job_queue, "jid" => jid }.reject { |_, v| v.nil? },
121
+ auto: true
121
122
  )
122
123
 
123
124
  span = client.tracing.start_span(
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require_relative "../sampling"
3
+ require_relative "../sanitizer"
3
4
 
4
5
  module AllStak
5
6
  module Modules
@@ -8,13 +9,17 @@ module AllStak
8
9
  PATH = "/ingest/v1/errors".freeze
9
10
  MAX_BREADCRUMBS = 50
10
11
 
12
+ # Thread-local key for the per-thread breadcrumb ring buffer. Each
13
+ # request/job runs on its own thread, so a per-thread buffer keeps one
14
+ # thread's breadcrumb trail from bleeding into another concurrent
15
+ # request's captured exception.
16
+ BREADCRUMB_TLS_KEY = :allstak_breadcrumbs
17
+
11
18
  def initialize(transport, config, logger, session_id_provider: nil)
12
19
  @transport = transport
13
20
  @config = config
14
21
  @logger = logger
15
22
  @current_user = nil
16
- @breadcrumbs = []
17
- @breadcrumb_mutex = Mutex.new
18
23
  # Optional callable returning the active release-health session id, so
19
24
  # the backend's error consumer can mark the session errored/crashed.
20
25
  @session_id_provider = session_id_provider
@@ -28,28 +33,34 @@ module AllStak
28
33
  @current_user = nil
29
34
  end
30
35
 
31
- def add_breadcrumb(type:, message:, level: "info", data: nil)
32
- @breadcrumb_mutex.synchronize do
33
- @breadcrumbs.shift if @breadcrumbs.length >= MAX_BREADCRUMBS
34
- @breadcrumbs << {
35
- timestamp: Time.now.utc.iso8601(6),
36
- type: type,
37
- message: message,
38
- level: level,
39
- data: data
40
- }.compact
41
- end
36
+ # Append a breadcrumb to the current thread's ring buffer.
37
+ #
38
+ # `auto: true` marks the crumb as produced by an auto-instrumentation
39
+ # layer (Rack/Net::HTTP/ActiveRecord/log bridge); those are suppressed
40
+ # when `config.enable_auto_breadcrumbs` is false. Manual breadcrumbs
41
+ # (`auto: false`, the default) are always recorded so existing callers
42
+ # (e.g. the Sidekiq middleware) keep working unchanged. Fail-open: a
43
+ # malformed crumb never raises into the host.
44
+ def add_breadcrumb(type:, message:, level: "info", data: nil, auto: false)
45
+ return if auto && !auto_breadcrumbs_enabled?
46
+ buffer = thread_breadcrumbs
47
+ buffer.shift if buffer.length >= MAX_BREADCRUMBS
48
+ buffer << {
49
+ timestamp: Time.now.utc.iso8601(6),
50
+ type: type,
51
+ message: message,
52
+ level: level,
53
+ data: data
54
+ }.compact
55
+ nil
56
+ rescue StandardError
57
+ nil
42
58
  end
43
59
 
44
60
  def capture_exception(exc, level: "error", user: nil, request_context: nil, trace_id: nil, metadata: nil)
45
61
  return nil if @transport.disabled?
46
62
  begin
47
- crumbs = @breadcrumb_mutex.synchronize do
48
- next nil if @breadcrumbs.empty?
49
- out = @breadcrumbs.dup
50
- @breadcrumbs.clear
51
- out
52
- end
63
+ crumbs = drain_breadcrumbs
53
64
 
54
65
  payload = {
55
66
  exceptionClass: exc.class.name,
@@ -76,7 +87,9 @@ module AllStak
76
87
  payload.delete(:user) if payload[:user]&.empty?
77
88
  payload.delete(:requestContext) if payload[:requestContext]&.empty?
78
89
 
79
- # Sampling first, then before_send, then transport (which scrubs).
90
+ # Sampling first, then pre-hook scrub, before_send, and final
91
+ # transport scrub. Hooks never see raw secrets and cannot reintroduce
92
+ # values that escape the wire-path sanitizer.
80
93
  return nil unless Sampling.sampled?(@config.sample_rate)
81
94
  payload = apply_before_send(payload)
82
95
  return nil if payload.nil?
@@ -119,7 +132,8 @@ module AllStak
119
132
  payload.delete(:user) if payload[:user]&.empty?
120
133
  payload.delete(:requestContext) if payload[:requestContext]&.empty?
121
134
 
122
- # Sampling first, then before_send, then transport (which scrubs).
135
+ # Sampling first, then pre-hook scrub, before_send, and final
136
+ # transport scrub.
123
137
  return nil unless Sampling.sampled?(@config.sample_rate)
124
138
  payload = apply_before_send(payload)
125
139
  return nil if payload.nil?
@@ -140,6 +154,28 @@ module AllStak
140
154
 
141
155
  private
142
156
 
157
+ # The current thread's breadcrumb ring buffer (lazily created).
158
+ def thread_breadcrumbs
159
+ Thread.current[BREADCRUMB_TLS_KEY] ||= []
160
+ end
161
+
162
+ # Snapshot + clear the current thread's breadcrumbs for attachment to an
163
+ # outgoing event. Returns nil when empty so the payload omits the key.
164
+ def drain_breadcrumbs
165
+ buffer = Thread.current[BREADCRUMB_TLS_KEY]
166
+ return nil if buffer.nil? || buffer.empty?
167
+ out = buffer.dup
168
+ buffer.clear
169
+ out
170
+ end
171
+
172
+ # Whether auto-emitted breadcrumbs are enabled. Defaults to true if the
173
+ # (possibly older/stubbed) config doesn't expose the flag.
174
+ def auto_breadcrumbs_enabled?
175
+ return true unless @config.respond_to?(:enable_auto_breadcrumbs)
176
+ @config.enable_auto_breadcrumbs != false
177
+ end
178
+
143
179
  # Resolve the active release-health session id via the injected provider.
144
180
  # Fail-open: any error yields nil so capture is never blocked.
145
181
  def current_session_id
@@ -151,19 +187,44 @@ module AllStak
151
187
  end
152
188
 
153
189
  # Run the user-supplied before_send hook. Returns the (possibly modified)
154
- # event, or nil to drop. Fail-open: if the hook raises, log and return
155
- # the ORIGINAL event so telemetry is never lost to a buggy hook.
190
+ # event, or nil to drop. The hook receives a sanitized copy and the
191
+ # transport sanitizes again after the hook. Fail-open: if the hook raises,
192
+ # log and return the sanitized event so telemetry is never lost to a buggy
193
+ # hook and raw secrets are not exposed.
156
194
  def apply_before_send(payload)
157
195
  hook = @config.before_send
158
- return payload unless hook.respond_to?(:call)
196
+ sanitized = sanitize_before_send(payload)
197
+ return sanitized unless hook.respond_to?(:call)
159
198
  begin
160
- hook.call(payload)
199
+ hook.call(sanitized)
161
200
  rescue => e
162
- @logger.warn("[AllStak] before_send raised; sending original event: #{e.class}: #{e.message}")
163
- payload
201
+ @logger.warn("[AllStak] before_send raised; sending sanitized event: #{e.class}: #{e.message}")
202
+ sanitized
164
203
  end
165
204
  end
166
205
 
206
+ def sanitize_before_send(payload)
207
+ AllStak::Sanitizer.scrub(payload, **sanitizer_options)
208
+ rescue => e
209
+ @logger.warn("[AllStak] pre-before_send sanitizer failed; sending redacted event: #{e.class}: #{e.message}")
210
+ redacted_payload(payload)
211
+ end
212
+
213
+ def sanitizer_options
214
+ {
215
+ extra_denylist: @config.respond_to?(:extra_denylist) ? @config.extra_denylist : nil,
216
+ send_default_pii: @config.respond_to?(:send_default_pii?) ? @config.send_default_pii? : false
217
+ }
218
+ end
219
+
220
+ def redacted_payload(payload)
221
+ out = payload.dup
222
+ out[:message] = AllStak::Sanitizer::REDACTED
223
+ out[:metadata] = { "redacted" => true }
224
+ out.delete(:breadcrumbs)
225
+ out
226
+ end
227
+
167
228
  def extract_frames(exc)
168
229
  return [] unless exc.backtrace.is_a?(Array)
169
230
  exc.backtrace.first(50)
@@ -5,10 +5,14 @@ module AllStak
5
5
  PATH = "/ingest/v1/logs".freeze
6
6
  VALID_LEVELS = %w[debug info warn error fatal].freeze
7
7
 
8
- def initialize(transport, config, logger)
8
+ def initialize(transport, config, logger, breadcrumb_sink: nil)
9
9
  @transport = transport
10
10
  @config = config
11
11
  @logger = logger
12
+ # Optional callable invoked for each accepted log so AllStak.log.*
13
+ # entries also surface as breadcrumbs on the next captured exception.
14
+ # Injected by the client; nil keeps Logs standalone (and recursion-free).
15
+ @breadcrumb_sink = breadcrumb_sink
12
16
  @buffer = Transport::FlushBuffer.new(
13
17
  name: "logs",
14
18
  max_size: config.buffer_size,
@@ -37,6 +41,8 @@ module AllStak
37
41
  metadata: @config.release_tags.merge(metadata || {})
38
42
  }.compact
39
43
  @buffer.push(payload)
44
+ emit_breadcrumb(level, message, trace_id: trace_id, span_id: span_id)
45
+ nil
40
46
  end
41
47
 
42
48
  def debug(msg, **kw); log("debug", msg, **kw); end
@@ -55,6 +61,22 @@ module AllStak
55
61
 
56
62
  private
57
63
 
64
+ # Bridge an accepted log into a breadcrumb via the injected sink. The
65
+ # sink is the errors module's add_breadcrumb (auto-gated), so this is a
66
+ # no-op when breadcrumbs are disabled or no sink was wired. Fail-open.
67
+ def emit_breadcrumb(level, message, trace_id: nil, span_id: nil)
68
+ sink = @breadcrumb_sink
69
+ return unless sink.respond_to?(:call)
70
+ sink.call(
71
+ type: "log",
72
+ message: message.to_s,
73
+ level: level,
74
+ data: { "traceId" => trace_id, "spanId" => span_id }.reject { |_, v| v.nil? }
75
+ )
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
58
80
  def normalize_level(level)
59
81
  lv = level.to_s.downcase
60
82
  lv = "warn" if lv == "warning"
@@ -11,13 +11,13 @@
11
11
  # Hash keys against the canonical denylist. Conforms to the canonical
12
12
  # AllStak SDK denylist defined in docs/standards/sdk-platform-standards.md.
13
13
  #
14
- # 2. VALUE-PATTERN redaction (Sentry data-scrubbing parity): scans free-text
14
+ # 2. VALUE-PATTERN redaction (data scrubbing): scans free-text
15
15
  # *string values* for PII that leaks regardless of key name. Two tiers:
16
16
  # A) ALWAYS scrubbed — credit-card numbers that pass the Luhn checksum,
17
17
  # and US SSNs written with hyphens. High-risk financial/identity data
18
18
  # never legitimately wanted in telemetry.
19
19
  # B) Scrubbed UNLESS send_default_pii — email addresses and IPv4
20
- # addresses. Default send_default_pii=false matches Sentry.
20
+ # addresses. Default send_default_pii=false (privacy-safe).
21
21
  #
22
22
  # Semantics:
23
23
  # - Key match: case-insensitive substring match on Hash keys.
@@ -133,7 +133,7 @@ module AllStak
133
133
 
134
134
  # Top-level subtrees that are never value-scrubbed. `user` holds data the
135
135
  # caller explicitly set via setUser (intentional identification — ships as
136
- # before, matching Sentry). `frames`/`stackTrace` hold structured stack
136
+ # before). `frames`/`stackTrace` hold structured stack
137
137
  # frames whose filenames/functions must not be corrupted.
138
138
  VALUE_SCRUB_SKIP_SUBTREES = %w[
139
139
  user
@@ -172,7 +172,7 @@ module AllStak
172
172
  # may extend but not narrow the canonical list.
173
173
  # @param send_default_pii [Boolean] when true, the tier-B value scrubbers
174
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).
175
+ # (credit card, SSN) is ALWAYS applied. Default false (privacy-safe).
176
176
  # @param values [Boolean] when false, only key-name redaction runs (no
177
177
  # value-pattern scrubbing). Useful for an intermediate pre-scrub (e.g.
178
178
  # Sidekiq job args) where the wire-path scrub will value-scrub later with
@@ -23,7 +23,7 @@ module AllStak
23
23
  PATH_END = "/ingest/v1/sessions/end".freeze
24
24
 
25
25
  # Lifecycle status. Vocabulary matches the backend `/sessions/end` contract
26
- # and Sentry's release-health conventions:
26
+ # and standard release-health conventions:
27
27
  # ok — ended normally, at most non-fatal logs.
28
28
  # errored — at least one HANDLED error captured; process kept running.
29
29
  # crashed — an UNHANDLED/fatal exception ended the process.
@@ -15,9 +15,8 @@ module AllStak
15
15
  # { "v" => 1, "path" => "/ingest/v1/errors", "payload" => {...},
16
16
  # "ts" => 1700000000.123 }
17
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.
18
+ # Offline durability: an on-disk transport cache events that cannot be
19
+ # delivered are persisted (PII-scrubbed) and replayed on the next init.
21
20
  #
22
21
  # HARD invariants:
23
22
  # * Fail-open everywhere. A read-only FS, a sandboxed/serverless runtime,
@@ -196,7 +196,7 @@ module AllStak
196
196
 
197
197
  # Sanitizer options derived from config. Guarded with respond_to? so a
198
198
  # bare/stub config (some transport unit tests) still scrubs with safe
199
- # defaults: PII off (Sentry parity), no extra denylist.
199
+ # defaults: PII off (privacy-safe), no extra denylist.
200
200
  def scrub_options
201
201
  {
202
202
  send_default_pii: @config.respond_to?(:send_default_pii?) ? @config.send_default_pii? : false,
@@ -1,3 +1,3 @@
1
1
  module AllStak
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/allstak.rb CHANGED
@@ -21,6 +21,10 @@ require_relative "allstak/integrations/rack"
21
21
  require_relative "allstak/integrations/active_record"
22
22
  require_relative "allstak/integrations/net_http"
23
23
  require_relative "allstak/integrations/sidekiq"
24
+ # Optional structured-log adapter. Defined on require but NOT auto-attached —
25
+ # it only ships logs once the app opts in via Logger.attach_to_rails! /
26
+ # Logger.broadcast, so existing logging behavior is preserved by default.
27
+ require_relative "allstak/integrations/logger"
24
28
  # The Rails Railtie self-installs on require when Rails is present, and is a
25
29
  # guarded no-op otherwise. Loading it here means Rails apps that `require
26
30
  # "allstak"` get the Rack middleware auto-wired without manual setup.
@@ -163,6 +167,15 @@ module AllStak
163
167
  AllStak::GlobalHandler.capture_unhandled(exc)
164
168
  end
165
169
 
170
+ # Manually record a breadcrumb on the current thread's ring buffer. It is
171
+ # attached to the next captured exception on this thread. Cross-SDK parity
172
+ # with JS addBreadcrumb / Python add_breadcrumb. Safe no-op when the SDK is
173
+ # not configured. Manual breadcrumbs are always recorded (independent of
174
+ # the enable_auto_breadcrumbs toggle, which only gates auto-instrumentation).
175
+ def add_breadcrumb(type:, message:, level: "info", data: nil)
176
+ @client&.errors&.add_breadcrumb(type: type, message: message, level: level, data: data)
177
+ end
178
+
166
179
  def set_user(**kw)
167
180
  @client&.set_user(**kw)
168
181
  end
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.2.1
4
+ version: 0.3.0
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-29 00:00:00.000000000 Z
11
+ date: 2026-05-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -70,6 +70,7 @@ files:
70
70
  - lib/allstak/config.rb
71
71
  - lib/allstak/global_handler.rb
72
72
  - lib/allstak/integrations/active_record.rb
73
+ - lib/allstak/integrations/logger.rb
73
74
  - lib/allstak/integrations/net_http.rb
74
75
  - lib/allstak/integrations/rack.rb
75
76
  - lib/allstak/integrations/rails.rb