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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/README.md +110 -233
- data/allstak.gemspec +10 -9
- data/lib/allstak/client.rb +66 -3
- data/lib/allstak/config.rb +259 -3
- data/lib/allstak/global_handler.rb +100 -0
- data/lib/allstak/integrations/active_record.rb +18 -0
- data/lib/allstak/integrations/logger.rb +201 -0
- data/lib/allstak/integrations/net_http.rb +27 -1
- data/lib/allstak/integrations/rack.rb +71 -10
- data/lib/allstak/integrations/rails.rb +59 -0
- data/lib/allstak/integrations/sidekiq.rb +184 -0
- data/lib/allstak/modules/database.rb +4 -1
- data/lib/allstak/modules/errors.rb +164 -22
- data/lib/allstak/modules/http_monitor.rb +7 -2
- data/lib/allstak/modules/logs.rb +28 -3
- data/lib/allstak/modules/tracing.rb +33 -2
- data/lib/allstak/propagation.rb +48 -0
- data/lib/allstak/sampling.rb +38 -0
- data/lib/allstak/sanitizer.rb +322 -0
- data/lib/allstak/session_tracker.rb +216 -0
- data/lib/allstak/transport/event_spool.rb +227 -0
- data/lib/allstak/transport/http_transport.rb +168 -5
- data/lib/allstak/version.rb +1 -1
- data/lib/allstak.rb +90 -1
- metadata +24 -29
|
@@ -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:
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|