allstak 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1,4 +1,6 @@
1
1
  require "net/http"
2
+ require "securerandom"
3
+ require "allstak/propagation"
2
4
 
3
5
  module AllStak
4
6
  module Integrations
@@ -38,6 +40,10 @@ module AllStak
38
40
  client = AllStak.client
39
41
  # Short-circuit: do NOT instrument our own ingest calls
40
42
  return super if host.include?("ingest") || host_matches_allstak?(host)
43
+ trace_id = client.tracing.current_trace_id
44
+ span_id = client.tracing.current_span_id
45
+ request_id = SecureRandom.hex(16)
46
+ AllStak::Propagation.apply_request_headers(req, trace_id: trace_id, request_id: request_id, span_id: span_id, sampled: client.tracing.current_trace_sampled?)
41
47
 
42
48
  begin
43
49
  response = super
@@ -59,9 +65,29 @@ module AllStak
59
65
  duration_ms: duration,
60
66
  request_size: req_size,
61
67
  response_size: resp_size,
62
- trace_id: client.tracing.current_trace_id,
68
+ trace_id: trace_id,
69
+ request_id: request_id,
70
+ span_id: span_id,
63
71
  error_fingerprint: error_fp
64
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
+ )
65
91
  rescue
66
92
  # never raise into host
67
93
  end
@@ -1,3 +1,6 @@
1
+ require "securerandom"
2
+ require "allstak/propagation"
3
+
1
4
  module AllStak
2
5
  module Integrations
3
6
  module Rack
@@ -20,14 +23,23 @@ module AllStak
20
23
  start = now_ms
21
24
  started_at = Time.now.utc.iso8601(3)
22
25
 
23
- # Trace id — adopt incoming or mint fresh per request
24
- incoming = env["HTTP_X_ALLSTAK_TRACE_ID"] || env["HTTP_TRACEPARENT"]
25
- if incoming && !incoming.empty?
26
- client.tracing.set_trace_id(incoming)
26
+ trace_id = trace_id_from_env(env)
27
+ parent_span_id = parent_span_id_from_env(env)
28
+ request_id = env["HTTP_X_REQUEST_ID"] || env["HTTP_X_ALLSTAK_REQUEST_ID"] || SecureRandom.hex(16)
29
+ if trace_id && !trace_id.empty?
30
+ client.tracing.set_trace_id(trace_id)
27
31
  else
28
32
  client.tracing.reset_trace
29
33
  end
30
34
  trace_id = client.tracing.current_trace_id
35
+ span = client.tracing.start_span(
36
+ "http.server",
37
+ description: "#{env["REQUEST_METHOD"] || "GET"} #{env["PATH_INFO"] || "/"}",
38
+ tags: {
39
+ "http.method" => env["REQUEST_METHOD"] || "GET",
40
+ "http.route" => env["PATH_INFO"] || "/"
41
+ }
42
+ )
31
43
 
32
44
  status = 0
33
45
  headers = {}
@@ -61,18 +73,41 @@ module AllStak
61
73
  request_size: req_size,
62
74
  response_size: resp_size || 0,
63
75
  trace_id: trace_id,
76
+ request_id: request_id,
77
+ span_id: span.span_id,
78
+ parent_span_id: parent_span_id,
64
79
  user_id: user_id
65
80
  )
81
+ span.set_tag("http.status_code", status.to_i.to_s)
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
+ )
66
100
  rescue => err
67
101
  # never raise into host
68
102
  config.debug && warn("[AllStak] rack request capture failed: #{err.message}")
69
103
  end
70
104
  end
105
+ span.finish(status.to_i >= 500 || captured ? "error" : "ok") unless span.finished?
71
106
 
72
107
  # Exception capture
73
108
  if captured && config.capture_unhandled_exceptions
74
109
  begin
75
- user_ctx = config.capture_user_context ? build_user_context(env) : nil
110
+ user_ctx = config.capture_user_context ? build_user_context(env, config) : nil
76
111
  req_ctx = AllStak::Models::RequestContext.new(
77
112
  method: env["REQUEST_METHOD"],
78
113
  path: env["PATH_INFO"],
@@ -85,7 +120,8 @@ module AllStak
85
120
  "http.path" => env["PATH_INFO"],
86
121
  "http.host" => env["HTTP_HOST"],
87
122
  "http.status" => status.to_i == 0 ? 500 : status.to_i,
88
- "traceId" => trace_id
123
+ "traceId" => trace_id,
124
+ "requestId" => request_id
89
125
  }
90
126
  client.errors.capture_exception(
91
127
  captured,
@@ -99,8 +135,14 @@ module AllStak
99
135
  end
100
136
  end
101
137
 
102
- # Best-effort response header for downstream trace linkage
103
- headers["X-AllStak-Trace-Id"] = trace_id if headers && !captured
138
+ # Best-effort response headers for downstream trace linkage.
139
+ AllStak::Propagation.apply_headers(
140
+ headers,
141
+ trace_id: trace_id,
142
+ request_id: request_id,
143
+ span_id: span.span_id,
144
+ sampled: client.tracing.current_trace_sampled?
145
+ ) if headers && !captured
104
146
  end
105
147
 
106
148
  [status, headers, body]
@@ -112,6 +154,20 @@ module AllStak
112
154
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
113
155
  end
114
156
 
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"]
162
+ end
163
+
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"]
169
+ end
170
+
115
171
  def extract_user_id(env)
116
172
  # Rack-standard: env["warden"]? env["rack.session"]?
117
173
  # Apps can set env["allstak.user_id"] directly.
@@ -123,11 +179,16 @@ module AllStak
123
179
  nil
124
180
  end
125
181
 
126
- def build_user_context(env)
182
+ def build_user_context(env, config = nil)
127
183
  id = extract_user_id(env)
128
184
  email = env["allstak.user_email"]
129
185
  return nil if id.nil? && email.nil?
130
- ip = env["REMOTE_ADDR"] || env["HTTP_X_FORWARDED_FOR"]
186
+ # The client IP here is AUTO-collected by the middleware (not set
187
+ # explicitly by the app via setUser). Privacy default: drop it unless
188
+ # the caller opted into PII via send_default_pii. Guarded so a nil/old
189
+ # config defaults to the privacy-preserving behavior.
190
+ send_pii = config.respond_to?(:send_default_pii?) ? config.send_default_pii? : false
191
+ ip = send_pii ? (env["REMOTE_ADDR"] || env["HTTP_X_FORWARDED_FOR"]) : nil
131
192
  AllStak::Models::UserContext.new(id: id, email: email, ip: ip)
132
193
  end
133
194
  end
@@ -0,0 +1,59 @@
1
+ require_relative "rack"
2
+
3
+ module AllStak
4
+ module Integrations
5
+ module Rails
6
+ # Auto-install the AllStak Rack middleware into a Rails application's
7
+ # middleware stack so Rails apps get inbound request telemetry, trace
8
+ # propagation, and unhandled-exception capture WITHOUT any manual
9
+ # `config.middleware.use` wiring.
10
+ #
11
+ # `Railtie` is only defined (and the initializer only registered) when
12
+ # `Rails::Railtie` is available, so requiring this file is a no-op in
13
+ # non-Rails processes. The middleware-insertion itself is idempotent:
14
+ # the Rack stack rejects a middleware that's already present, and we
15
+ # guard explicitly as well.
16
+ def self.install!
17
+ return false unless defined?(::Rails::Railtie)
18
+ return true if defined?(AllStak::Integrations::Rails::Railtie)
19
+
20
+ railtie_class = Class.new(::Rails::Railtie) do
21
+ railtie_name "allstak" if respond_to?(:railtie_name)
22
+
23
+ initializer "allstak.insert_middleware" do |app|
24
+ AllStak::Integrations::Rails.insert_middleware(app)
25
+ end
26
+ end
27
+
28
+ const_set(:Railtie, railtie_class)
29
+ true
30
+ end
31
+
32
+ # Insert the Rack middleware into the given Rails app's middleware stack,
33
+ # unless it's already present. Returns true when (now) present.
34
+ def self.insert_middleware(app)
35
+ stack = app.config.middleware
36
+ mw = AllStak::Integrations::Rack::Middleware
37
+ return true if middleware_present?(stack, mw)
38
+ stack.use(mw)
39
+ true
40
+ rescue => e
41
+ # Never let observability wiring break Rails boot.
42
+ warn("[AllStak] failed to insert Rack middleware: #{e.message}") if AllStak.respond_to?(:logger) && AllStak.logger&.debug?
43
+ false
44
+ end
45
+
46
+ # Best-effort idempotency check across Rails middleware-stack versions.
47
+ def self.middleware_present?(stack, mw)
48
+ return stack.include?(mw) if stack.respond_to?(:include?)
49
+ false
50
+ rescue
51
+ false
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # Eagerly attempt installation when Rails is already loaded at require time.
58
+ # Safe no-op otherwise.
59
+ AllStak::Integrations::Rails.install! if defined?(::Rails::Railtie)
@@ -0,0 +1,184 @@
1
+ require_relative "../sanitizer"
2
+
3
+ module AllStak
4
+ module Integrations
5
+ # Sidekiq integration.
6
+ #
7
+ # Provides a Sidekiq *server* middleware that:
8
+ # 1. Starts a fresh trace per job (so spans/telemetry produced inside the
9
+ # job link together and don't bleed across jobs on a reused thread).
10
+ # 2. Wraps job execution in a "queue.process" span + breadcrumb.
11
+ # 3. Auto-captures the exception when a job raises, attaching worker
12
+ # class, jid, queue, and (sanitized) args as metadata, then re-raises
13
+ # so Sidekiq's own retry machinery still runs.
14
+ #
15
+ # It also registers a `death_handler` so jobs that exhaust their retries
16
+ # are captured once more with `mechanism=sidekiq.death` for visibility.
17
+ #
18
+ # Installation is guarded: `install!` is a graceful no-op when Sidekiq is
19
+ # not loaded in the host process, and is idempotent.
20
+ module Sidekiq
21
+ def self.install!
22
+ return if @installed
23
+ return unless defined?(::Sidekiq)
24
+
25
+ ::Sidekiq.configure_server do |sidekiq_config|
26
+ sidekiq_config.server_middleware do |chain|
27
+ chain.add(AllStak::Integrations::Sidekiq::Middleware) unless chain.exists?(AllStak::Integrations::Sidekiq::Middleware)
28
+ end
29
+
30
+ # Retries-exhausted handler. The death handler API differs across
31
+ # Sidekiq majors; both forms accept a (job, exception) callable.
32
+ if sidekiq_config.respond_to?(:death_handlers)
33
+ sidekiq_config.death_handlers << lambda do |job, exception|
34
+ AllStak::Integrations::Sidekiq.capture_death(job, exception)
35
+ end
36
+ end
37
+ end
38
+
39
+ @installed = true
40
+ end
41
+
42
+ def self.installed?
43
+ @installed == true
44
+ end
45
+
46
+ # Capture a job that has exhausted all retries (Sidekiq "death").
47
+ # Best-effort; never raises into Sidekiq's death-handler loop.
48
+ def self.capture_death(job, exception)
49
+ return unless AllStak.initialized?
50
+ return if exception.nil?
51
+ client = AllStak.client
52
+ config = client.config
53
+ return unless config.capture_unhandled_exceptions
54
+
55
+ job = job || {}
56
+ meta = job_metadata(job).merge(
57
+ "mechanism" => "sidekiq.death",
58
+ "handled" => false
59
+ )
60
+ client.errors.capture_exception(exception, metadata: meta)
61
+ rescue => e
62
+ begin
63
+ AllStak.client.config.debug && warn("[AllStak] sidekiq death capture failed: #{e.message}")
64
+ rescue
65
+ nil
66
+ end
67
+ end
68
+
69
+ # Build sanitized job metadata from a Sidekiq job hash.
70
+ # Args are routed through the Sanitizer (KEY-NAME redaction only) so
71
+ # secrets in argument hashes are redacted here; value-pattern PII
72
+ # scrubbing (email/IP/CC/SSN, gated by send_default_pii) is applied
73
+ # authoritatively on the wire path, so we don't double-scrub free text
74
+ # at this layer.
75
+ def self.job_metadata(job)
76
+ job ||= {}
77
+ args = job["args"]
78
+ sanitized_args =
79
+ begin
80
+ AllStak::Sanitizer.scrub(args, values: false) if args
81
+ rescue
82
+ nil
83
+ end
84
+ {
85
+ "sidekiq.class" => job["class"] || job["wrapped"],
86
+ "sidekiq.jid" => job["jid"],
87
+ "sidekiq.queue" => job["queue"],
88
+ "sidekiq.retry_count" => job["retry_count"],
89
+ "sidekiq.args" => sanitized_args
90
+ }.reject { |_, v| v.nil? }
91
+ end
92
+
93
+ # Sidekiq server middleware. Sidekiq calls `#call(worker, job, queue)`
94
+ # and expects the middleware to `yield` to run the job.
95
+ class Middleware
96
+ def call(worker, job, queue)
97
+ unless AllStak.initialized?
98
+ return yield
99
+ end
100
+
101
+ client = AllStak.client
102
+ config = client.config
103
+
104
+ # Each job is its own trace root unless an upstream producer
105
+ # propagated a trace id into the job payload.
106
+ incoming_trace = job.is_a?(Hash) ? (job["allstak_trace_id"] || job["trace_id"]) : nil
107
+ if incoming_trace && !incoming_trace.to_s.empty?
108
+ client.tracing.set_trace_id(incoming_trace.to_s)
109
+ else
110
+ client.tracing.reset_trace
111
+ end
112
+
113
+ worker_class = worker_class_name(worker, job)
114
+ job_queue = (job.is_a?(Hash) ? job["queue"] : nil) || queue
115
+ jid = job.is_a?(Hash) ? job["jid"] : nil
116
+
117
+ client.errors.add_breadcrumb(
118
+ type: "sidekiq",
119
+ message: "process #{worker_class}",
120
+ data: { "queue" => job_queue, "jid" => jid }.reject { |_, v| v.nil? },
121
+ auto: true
122
+ )
123
+
124
+ span = client.tracing.start_span(
125
+ "queue.process",
126
+ description: worker_class,
127
+ tags: {
128
+ "messaging.system" => "sidekiq",
129
+ "messaging.destination" => job_queue.to_s,
130
+ "messaging.message_id" => jid.to_s
131
+ }.reject { |_, v| v.to_s.empty? }
132
+ )
133
+
134
+ captured = nil
135
+ begin
136
+ yield
137
+ rescue Exception => e # rubocop:disable Lint/RescueException
138
+ captured = e
139
+ raise
140
+ ensure
141
+ span.finish(captured ? "error" : "ok") unless span.finished?
142
+
143
+ if captured && config.capture_unhandled_exceptions
144
+ begin
145
+ meta = AllStak::Integrations::Sidekiq.job_metadata(job_hash(job, worker_class, job_queue, jid)).merge(
146
+ "mechanism" => "sidekiq",
147
+ "handled" => false,
148
+ "traceId" => client.tracing.current_trace_id
149
+ )
150
+ client.errors.capture_exception(
151
+ captured,
152
+ trace_id: client.tracing.current_trace_id,
153
+ metadata: meta
154
+ )
155
+ rescue => err
156
+ config.debug && warn("[AllStak] sidekiq exception capture failed: #{err.message}")
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def worker_class_name(worker, job)
165
+ if job.is_a?(Hash) && (job["wrapped"] || job["class"])
166
+ return (job["wrapped"] || job["class"]).to_s
167
+ end
168
+ worker.respond_to?(:class) ? worker.class.name : worker.to_s
169
+ end
170
+
171
+ # Normalize the job into a Hash for metadata extraction. Sidekiq always
172
+ # passes a Hash, but we tolerate non-Hash defensively.
173
+ def job_hash(job, worker_class, queue, jid)
174
+ return job if job.is_a?(Hash)
175
+ {
176
+ "class" => worker_class,
177
+ "queue" => queue,
178
+ "jid" => jid
179
+ }
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -78,7 +78,10 @@ module AllStak
78
78
  rescue Transport::AllStakAuthError
79
79
  return
80
80
  rescue Transport::AllStakTransportError => e
81
- @logger.debug("[AllStak] db batch transport error: #{e.message}")
81
+ # Retries exhausted / outage: persist the (scrubbed) batch for
82
+ # replay on the next init instead of dropping.
83
+ @transport.persist_failed(PATH, { queries: chunk })
84
+ @logger.debug("[AllStak] db batch transport error (spooled): #{e.message}")
82
85
  rescue => e
83
86
  @logger.debug("[AllStak] db batch unexpected error: #{e.message}")
84
87
  end