allstak 0.1.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 +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/allstak.gemspec +40 -0
- data/lib/allstak/client.rb +98 -0
- data/lib/allstak/config.rb +36 -0
- data/lib/allstak/integrations/active_record.rb +78 -0
- data/lib/allstak/integrations/net_http.rb +87 -0
- data/lib/allstak/integrations/rack.rb +136 -0
- data/lib/allstak/models/user_context.rb +43 -0
- data/lib/allstak/modules/cron.rb +54 -0
- data/lib/allstak/modules/database.rb +89 -0
- data/lib/allstak/modules/errors.rb +111 -0
- data/lib/allstak/modules/http_monitor.rb +79 -0
- data/lib/allstak/modules/logs.rb +79 -0
- data/lib/allstak/modules/tracing.rb +170 -0
- data/lib/allstak/transport/flush_buffer.rb +91 -0
- data/lib/allstak/transport/http_transport.rb +97 -0
- data/lib/allstak/version.rb +3 -0
- data/lib/allstak.rb +151 -0
- metadata +128 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
module AllStak
|
|
4
|
+
module Integrations
|
|
5
|
+
# Monkey-patches Net::HTTP#request to capture outbound HTTP calls as
|
|
6
|
+
# AllStak http-request telemetry with real timing, status, and size.
|
|
7
|
+
#
|
|
8
|
+
# No duplication: we patch at the #request level, which every Net::HTTP
|
|
9
|
+
# convenience method (get, post, post_form, etc.) funnels through.
|
|
10
|
+
module NetHTTP
|
|
11
|
+
def self.install!
|
|
12
|
+
return if @installed
|
|
13
|
+
::Net::HTTP.prepend(Patch)
|
|
14
|
+
@installed = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.installed?
|
|
18
|
+
@installed == true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Patch
|
|
22
|
+
def request(req, body = nil, &block)
|
|
23
|
+
return super unless AllStak.initialized?
|
|
24
|
+
|
|
25
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
host = (req["Host"] || address).to_s
|
|
27
|
+
path = begin
|
|
28
|
+
req.path.to_s
|
|
29
|
+
rescue
|
|
30
|
+
"/"
|
|
31
|
+
end
|
|
32
|
+
method = req.method.to_s.upcase
|
|
33
|
+
status = 0
|
|
34
|
+
resp_size = 0
|
|
35
|
+
req_size = req.body.to_s.bytesize rescue 0
|
|
36
|
+
error_fp = nil
|
|
37
|
+
|
|
38
|
+
client = AllStak.client
|
|
39
|
+
# Short-circuit: do NOT instrument our own ingest calls
|
|
40
|
+
return super if host.include?("ingest") || host_matches_allstak?(host)
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
response = super
|
|
44
|
+
status = response.code.to_i
|
|
45
|
+
resp_size = response.body.to_s.bytesize rescue 0
|
|
46
|
+
response
|
|
47
|
+
rescue => e
|
|
48
|
+
error_fp = e.class.name
|
|
49
|
+
raise
|
|
50
|
+
ensure
|
|
51
|
+
begin
|
|
52
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).to_i
|
|
53
|
+
client.http.record(
|
|
54
|
+
direction: "outbound",
|
|
55
|
+
method: method,
|
|
56
|
+
host: host,
|
|
57
|
+
path: path,
|
|
58
|
+
status_code: status,
|
|
59
|
+
duration_ms: duration,
|
|
60
|
+
request_size: req_size,
|
|
61
|
+
response_size: resp_size,
|
|
62
|
+
trace_id: client.tracing.current_trace_id,
|
|
63
|
+
error_fingerprint: error_fp
|
|
64
|
+
)
|
|
65
|
+
rescue
|
|
66
|
+
# never raise into host
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def host_matches_allstak?(h)
|
|
74
|
+
return false unless AllStak.initialized?
|
|
75
|
+
base = AllStak.client.config.host.to_s
|
|
76
|
+
return false if base.empty?
|
|
77
|
+
begin
|
|
78
|
+
uri = URI.parse(base)
|
|
79
|
+
!!(uri.host && h.include?(uri.host))
|
|
80
|
+
rescue
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module AllStak
|
|
2
|
+
module Integrations
|
|
3
|
+
module Rack
|
|
4
|
+
# Rack middleware that:
|
|
5
|
+
# 1. Starts a fresh trace per request (or adopts X-AllStak-Trace-Id / traceparent)
|
|
6
|
+
# 2. Captures inbound HTTP request telemetry
|
|
7
|
+
# 3. Auto-captures unhandled exceptions with full request context, user, stack, and trace link
|
|
8
|
+
# 4. Re-raises so the framework's exception handler runs
|
|
9
|
+
class Middleware
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
return @app.call(env) unless AllStak.initialized?
|
|
16
|
+
|
|
17
|
+
client = AllStak.client
|
|
18
|
+
config = client.config
|
|
19
|
+
|
|
20
|
+
start = now_ms
|
|
21
|
+
started_at = Time.now.utc.iso8601(3)
|
|
22
|
+
|
|
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)
|
|
27
|
+
else
|
|
28
|
+
client.tracing.reset_trace
|
|
29
|
+
end
|
|
30
|
+
trace_id = client.tracing.current_trace_id
|
|
31
|
+
|
|
32
|
+
status = 0
|
|
33
|
+
headers = {}
|
|
34
|
+
body = nil
|
|
35
|
+
captured = nil
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
status, headers, body = @app.call(env)
|
|
39
|
+
rescue => e
|
|
40
|
+
captured = e
|
|
41
|
+
status = 500 if status.to_i == 0
|
|
42
|
+
raise
|
|
43
|
+
ensure
|
|
44
|
+
duration = now_ms - start
|
|
45
|
+
|
|
46
|
+
# Request telemetry
|
|
47
|
+
if config.capture_http_requests
|
|
48
|
+
begin
|
|
49
|
+
req_size = env["CONTENT_LENGTH"].to_i
|
|
50
|
+
resp_size = headers && headers["Content-Length"].to_i
|
|
51
|
+
user_id = extract_user_id(env)
|
|
52
|
+
path = env["PATH_INFO"] || "/"
|
|
53
|
+
|
|
54
|
+
client.http.record(
|
|
55
|
+
direction: "inbound",
|
|
56
|
+
method: env["REQUEST_METHOD"] || "GET",
|
|
57
|
+
host: env["HTTP_HOST"] || "localhost",
|
|
58
|
+
path: path,
|
|
59
|
+
status_code: status.to_i,
|
|
60
|
+
duration_ms: duration,
|
|
61
|
+
request_size: req_size,
|
|
62
|
+
response_size: resp_size || 0,
|
|
63
|
+
trace_id: trace_id,
|
|
64
|
+
user_id: user_id
|
|
65
|
+
)
|
|
66
|
+
rescue => err
|
|
67
|
+
# never raise into host
|
|
68
|
+
config.debug && warn("[AllStak] rack request capture failed: #{err.message}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Exception capture
|
|
73
|
+
if captured && config.capture_unhandled_exceptions
|
|
74
|
+
begin
|
|
75
|
+
user_ctx = config.capture_user_context ? build_user_context(env) : nil
|
|
76
|
+
req_ctx = AllStak::Models::RequestContext.new(
|
|
77
|
+
method: env["REQUEST_METHOD"],
|
|
78
|
+
path: env["PATH_INFO"],
|
|
79
|
+
host: env["HTTP_HOST"],
|
|
80
|
+
status_code: status.to_i == 0 ? 500 : status.to_i,
|
|
81
|
+
user_agent: env["HTTP_USER_AGENT"]
|
|
82
|
+
)
|
|
83
|
+
meta = {
|
|
84
|
+
"http.method" => env["REQUEST_METHOD"],
|
|
85
|
+
"http.path" => env["PATH_INFO"],
|
|
86
|
+
"http.host" => env["HTTP_HOST"],
|
|
87
|
+
"http.status" => status.to_i == 0 ? 500 : status.to_i,
|
|
88
|
+
"traceId" => trace_id
|
|
89
|
+
}
|
|
90
|
+
client.errors.capture_exception(
|
|
91
|
+
captured,
|
|
92
|
+
user: user_ctx,
|
|
93
|
+
request_context: req_ctx,
|
|
94
|
+
trace_id: trace_id,
|
|
95
|
+
metadata: meta
|
|
96
|
+
)
|
|
97
|
+
rescue => err
|
|
98
|
+
config.debug && warn("[AllStak] rack exception capture failed: #{err.message}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Best-effort response header for downstream trace linkage
|
|
103
|
+
headers["X-AllStak-Trace-Id"] = trace_id if headers && !captured
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
[status, headers, body]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def now_ms
|
|
112
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_user_id(env)
|
|
116
|
+
# Rack-standard: env["warden"]? env["rack.session"]?
|
|
117
|
+
# Apps can set env["allstak.user_id"] directly.
|
|
118
|
+
return env["allstak.user_id"].to_s if env["allstak.user_id"]
|
|
119
|
+
if (session = env["rack.session"])
|
|
120
|
+
id = session["user_id"] || session[:user_id]
|
|
121
|
+
return id.to_s if id
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_user_context(env)
|
|
127
|
+
id = extract_user_id(env)
|
|
128
|
+
email = env["allstak.user_email"]
|
|
129
|
+
return nil if id.nil? && email.nil?
|
|
130
|
+
ip = env["REMOTE_ADDR"] || env["HTTP_X_FORWARDED_FOR"]
|
|
131
|
+
AllStak::Models::UserContext.new(id: id, email: email, ip: ip)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module AllStak
|
|
2
|
+
module Models
|
|
3
|
+
class UserContext
|
|
4
|
+
attr_accessor :id, :email, :ip
|
|
5
|
+
|
|
6
|
+
def initialize(id: nil, email: nil, ip: nil)
|
|
7
|
+
@id = id
|
|
8
|
+
@email = email
|
|
9
|
+
@ip = ip
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_h
|
|
13
|
+
out = {}
|
|
14
|
+
out[:id] = @id unless @id.nil?
|
|
15
|
+
out[:email] = @email unless @email.nil?
|
|
16
|
+
out[:ip] = @ip unless @ip.nil?
|
|
17
|
+
out
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class RequestContext
|
|
22
|
+
attr_accessor :method, :path, :host, :status_code, :user_agent
|
|
23
|
+
|
|
24
|
+
def initialize(method: nil, path: nil, host: nil, status_code: nil, user_agent: nil)
|
|
25
|
+
@method = method
|
|
26
|
+
@path = path
|
|
27
|
+
@host = host
|
|
28
|
+
@status_code = status_code
|
|
29
|
+
@user_agent = user_agent
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
out = {}
|
|
34
|
+
out[:method] = @method unless @method.nil?
|
|
35
|
+
out[:path] = @path unless @path.nil?
|
|
36
|
+
out[:host] = @host unless @host.nil?
|
|
37
|
+
out[:statusCode] = @status_code unless @status_code.nil?
|
|
38
|
+
out[:userAgent] = @user_agent unless @user_agent.nil?
|
|
39
|
+
out
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module AllStak
|
|
2
|
+
module Modules
|
|
3
|
+
# Cron / background-job monitoring. Backend auto-creates monitors on first ping.
|
|
4
|
+
class Cron
|
|
5
|
+
PATH = "/ingest/v1/heartbeat".freeze
|
|
6
|
+
|
|
7
|
+
def initialize(transport, logger, config = nil)
|
|
8
|
+
@transport = transport
|
|
9
|
+
@logger = logger
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Wrap a job in a block; heartbeat sent on exit.
|
|
14
|
+
# On success → status "success". On raise → status "failed" with message,
|
|
15
|
+
# then the exception is re-raised.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# AllStak.cron.job("daily-report") { generate_report }
|
|
19
|
+
def job(slug)
|
|
20
|
+
start = (Time.now.to_f * 1000).to_i
|
|
21
|
+
begin
|
|
22
|
+
result = yield
|
|
23
|
+
duration = (Time.now.to_f * 1000).to_i - start
|
|
24
|
+
ping(slug, "success", duration)
|
|
25
|
+
result
|
|
26
|
+
rescue => e
|
|
27
|
+
duration = (Time.now.to_f * 1000).to_i - start
|
|
28
|
+
ping(slug, "failed", duration, message: e.message)
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ping(slug, status, duration_ms, message: nil)
|
|
34
|
+
return false if @transport.disabled?
|
|
35
|
+
begin
|
|
36
|
+
payload = { slug: slug, status: status, durationMs: duration_ms }
|
|
37
|
+
payload[:message] = message if message
|
|
38
|
+
if @config
|
|
39
|
+
payload[:environment] = @config.environment if @config.respond_to?(:environment) && @config.environment
|
|
40
|
+
payload[:release] = @config.release if @config.respond_to?(:release) && @config.release
|
|
41
|
+
end
|
|
42
|
+
code, _ = @transport.post(PATH, payload)
|
|
43
|
+
code == 202
|
|
44
|
+
rescue Transport::AllStakAuthError
|
|
45
|
+
@logger.debug("[AllStak] cron ping skipped — SDK disabled")
|
|
46
|
+
false
|
|
47
|
+
rescue => e
|
|
48
|
+
@logger.debug("[AllStak] cron ping failed silently: #{e.message}")
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module AllStak
|
|
4
|
+
module Modules
|
|
5
|
+
# Database query telemetry, batched up to 100 per POST.
|
|
6
|
+
class Database
|
|
7
|
+
PATH = "/ingest/v1/db".freeze
|
|
8
|
+
BATCH_SIZE = 100
|
|
9
|
+
|
|
10
|
+
def initialize(transport, config, logger)
|
|
11
|
+
@transport = transport
|
|
12
|
+
@config = config
|
|
13
|
+
@logger = logger
|
|
14
|
+
@buffer = Transport::FlushBuffer.new(
|
|
15
|
+
name: "database",
|
|
16
|
+
max_size: config.buffer_size,
|
|
17
|
+
interval_ms: config.flush_interval_ms,
|
|
18
|
+
flush_proc: method(:flush_batch),
|
|
19
|
+
logger: logger
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(sql:, duration_ms:, status: "success", error_message: nil,
|
|
24
|
+
database_name: nil, database_type: nil, query_type: nil,
|
|
25
|
+
rows_affected: -1, trace_id: nil, span_id: nil)
|
|
26
|
+
return if @transport.disabled?
|
|
27
|
+
normalized = self.class.normalize_query(sql)
|
|
28
|
+
@buffer.push({
|
|
29
|
+
normalizedQuery: normalized,
|
|
30
|
+
queryHash: self.class.hash_query(normalized),
|
|
31
|
+
queryType: query_type || self.class.detect_query_type(normalized),
|
|
32
|
+
durationMs: [duration_ms.to_i, 0].max,
|
|
33
|
+
timestampMillis: (Time.now.to_f * 1000).to_i,
|
|
34
|
+
status: status,
|
|
35
|
+
errorMessage: error_message && error_message.to_s[0, 500],
|
|
36
|
+
databaseName: database_name,
|
|
37
|
+
databaseType: database_type,
|
|
38
|
+
service: @config.service_name,
|
|
39
|
+
environment: @config.environment,
|
|
40
|
+
release: @config.respond_to?(:release) ? @config.release : nil,
|
|
41
|
+
traceId: trace_id,
|
|
42
|
+
spanId: span_id,
|
|
43
|
+
rowsAffected: rows_affected
|
|
44
|
+
}.compact)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def flush
|
|
48
|
+
@buffer.flush
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def shutdown
|
|
52
|
+
@buffer.shutdown
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.normalize_query(sql)
|
|
56
|
+
s = sql.to_s.dup
|
|
57
|
+
s.gsub!(/'[^']*'/, "?")
|
|
58
|
+
s.gsub!(/\b\d+(\.\d+)?\b/, "?")
|
|
59
|
+
s.gsub!(/\s+/, " ")
|
|
60
|
+
s.strip
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.hash_query(normalized)
|
|
64
|
+
Digest::MD5.hexdigest(normalized)[0, 16]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.detect_query_type(sql)
|
|
68
|
+
first = sql.to_s.strip.split(/\s+/, 2).first.to_s.upcase
|
|
69
|
+
%w[SELECT INSERT UPDATE DELETE].include?(first) ? first : "OTHER"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def flush_batch(items)
|
|
75
|
+
items.each_slice(BATCH_SIZE) do |chunk|
|
|
76
|
+
begin
|
|
77
|
+
@transport.post(PATH, { queries: chunk })
|
|
78
|
+
rescue Transport::AllStakAuthError
|
|
79
|
+
return
|
|
80
|
+
rescue Transport::AllStakTransportError => e
|
|
81
|
+
@logger.debug("[AllStak] db batch transport error: #{e.message}")
|
|
82
|
+
rescue => e
|
|
83
|
+
@logger.debug("[AllStak] db batch unexpected error: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AllStak
|
|
4
|
+
module Modules
|
|
5
|
+
# Captures exceptions and sends them to AllStak.
|
|
6
|
+
class Errors
|
|
7
|
+
PATH = "/ingest/v1/errors".freeze
|
|
8
|
+
MAX_BREADCRUMBS = 50
|
|
9
|
+
|
|
10
|
+
def initialize(transport, config, logger)
|
|
11
|
+
@transport = transport
|
|
12
|
+
@config = config
|
|
13
|
+
@logger = logger
|
|
14
|
+
@current_user = nil
|
|
15
|
+
@breadcrumbs = []
|
|
16
|
+
@breadcrumb_mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_user(id: nil, email: nil, ip: nil)
|
|
20
|
+
@current_user = Models::UserContext.new(id: id, email: email, ip: ip)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear_user
|
|
24
|
+
@current_user = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_breadcrumb(type:, message:, level: "info", data: nil)
|
|
28
|
+
@breadcrumb_mutex.synchronize do
|
|
29
|
+
@breadcrumbs.shift if @breadcrumbs.length >= MAX_BREADCRUMBS
|
|
30
|
+
@breadcrumbs << {
|
|
31
|
+
timestamp: Time.now.utc.iso8601(6),
|
|
32
|
+
type: type,
|
|
33
|
+
message: message,
|
|
34
|
+
level: level,
|
|
35
|
+
data: data
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def capture_exception(exc, level: "error", user: nil, request_context: nil, trace_id: nil, metadata: nil)
|
|
41
|
+
return nil if @transport.disabled?
|
|
42
|
+
begin
|
|
43
|
+
crumbs = @breadcrumb_mutex.synchronize do
|
|
44
|
+
next nil if @breadcrumbs.empty?
|
|
45
|
+
out = @breadcrumbs.dup
|
|
46
|
+
@breadcrumbs.clear
|
|
47
|
+
out
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
payload = {
|
|
51
|
+
exceptionClass: exc.class.name,
|
|
52
|
+
message: exc.message.to_s.empty? ? exc.class.name : exc.message.to_s,
|
|
53
|
+
stackTrace: extract_frames(exc),
|
|
54
|
+
level: level,
|
|
55
|
+
environment: @config.environment,
|
|
56
|
+
release: @config.release,
|
|
57
|
+
traceId: trace_id,
|
|
58
|
+
user: (user || @current_user)&.to_h,
|
|
59
|
+
requestContext: request_context&.to_h,
|
|
60
|
+
metadata: metadata,
|
|
61
|
+
breadcrumbs: crumbs
|
|
62
|
+
}.compact
|
|
63
|
+
payload.delete(:user) if payload[:user]&.empty?
|
|
64
|
+
payload.delete(:requestContext) if payload[:requestContext]&.empty?
|
|
65
|
+
|
|
66
|
+
status, body = @transport.post(PATH, payload)
|
|
67
|
+
return nil unless status == 202
|
|
68
|
+
parsed = JSON.parse(body) rescue nil
|
|
69
|
+
parsed&.dig("data", "id")
|
|
70
|
+
rescue Transport::AllStakAuthError
|
|
71
|
+
nil
|
|
72
|
+
rescue => e
|
|
73
|
+
@logger.debug("[AllStak] capture_exception swallowed: #{e.class}: #{e.message}")
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def capture_error(exception_class, message, stack_trace: nil, level: "error", user: nil, request_context: nil, trace_id: nil, metadata: nil)
|
|
79
|
+
return nil if @transport.disabled?
|
|
80
|
+
begin
|
|
81
|
+
payload = {
|
|
82
|
+
exceptionClass: exception_class,
|
|
83
|
+
message: message,
|
|
84
|
+
stackTrace: stack_trace,
|
|
85
|
+
level: level,
|
|
86
|
+
environment: @config.environment,
|
|
87
|
+
release: @config.release,
|
|
88
|
+
traceId: trace_id,
|
|
89
|
+
user: (user || @current_user)&.to_h,
|
|
90
|
+
requestContext: request_context&.to_h,
|
|
91
|
+
metadata: metadata
|
|
92
|
+
}.compact
|
|
93
|
+
payload.delete(:user) if payload[:user]&.empty?
|
|
94
|
+
payload.delete(:requestContext) if payload[:requestContext]&.empty?
|
|
95
|
+
status, _ = @transport.post(PATH, payload)
|
|
96
|
+
status == 202 ? exception_class : nil
|
|
97
|
+
rescue => e
|
|
98
|
+
@logger.debug("[AllStak] capture_error swallowed: #{e.class}: #{e.message}")
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def extract_frames(exc)
|
|
106
|
+
return [] unless exc.backtrace.is_a?(Array)
|
|
107
|
+
exc.backtrace.first(50)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module AllStak
|
|
4
|
+
module Modules
|
|
5
|
+
# Buffers + batches HTTP request telemetry (inbound and outbound).
|
|
6
|
+
# Max batch: 100.
|
|
7
|
+
class HttpMonitor
|
|
8
|
+
PATH = "/ingest/v1/http-requests".freeze
|
|
9
|
+
MAX_BATCH = 100
|
|
10
|
+
|
|
11
|
+
def initialize(transport, config, logger)
|
|
12
|
+
@transport = transport
|
|
13
|
+
@config = config
|
|
14
|
+
@logger = logger
|
|
15
|
+
@buffer = Transport::FlushBuffer.new(
|
|
16
|
+
name: "http",
|
|
17
|
+
max_size: config.buffer_size,
|
|
18
|
+
interval_ms: config.flush_interval_ms,
|
|
19
|
+
flush_proc: method(:flush_batch),
|
|
20
|
+
logger: logger
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def record(direction:, method:, host:, path:, status_code:, duration_ms:,
|
|
25
|
+
request_size: 0, response_size: 0, trace_id: nil, user_id: nil,
|
|
26
|
+
error_fingerprint: nil, span_id: nil, parent_span_id: nil)
|
|
27
|
+
return if @transport.disabled?
|
|
28
|
+
item = {
|
|
29
|
+
direction: direction,
|
|
30
|
+
method: method.to_s.upcase,
|
|
31
|
+
host: host.to_s,
|
|
32
|
+
path: strip_query(path.to_s),
|
|
33
|
+
statusCode: status_code.to_i,
|
|
34
|
+
durationMs: [duration_ms.to_i, 0].max,
|
|
35
|
+
requestSize: request_size.to_i,
|
|
36
|
+
responseSize: response_size.to_i,
|
|
37
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
38
|
+
traceId: trace_id || SecureRandom.hex(16),
|
|
39
|
+
userId: user_id,
|
|
40
|
+
errorFingerprint: error_fingerprint,
|
|
41
|
+
spanId: span_id,
|
|
42
|
+
parentSpanId: parent_span_id,
|
|
43
|
+
environment: @config.environment,
|
|
44
|
+
release: @config.release
|
|
45
|
+
}.compact
|
|
46
|
+
@buffer.push(item)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def flush
|
|
50
|
+
@buffer.flush
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def shutdown
|
|
54
|
+
@buffer.shutdown
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def strip_query(path)
|
|
60
|
+
idx = path.index("?")
|
|
61
|
+
idx ? path[0...idx] : path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def flush_batch(items)
|
|
65
|
+
items.each_slice(MAX_BATCH) do |chunk|
|
|
66
|
+
begin
|
|
67
|
+
@transport.post(PATH, { requests: chunk })
|
|
68
|
+
rescue Transport::AllStakAuthError
|
|
69
|
+
return
|
|
70
|
+
rescue Transport::AllStakTransportError => e
|
|
71
|
+
@logger.debug("[AllStak] http batch transport error: #{e.message}")
|
|
72
|
+
rescue => e
|
|
73
|
+
@logger.debug("[AllStak] http batch unexpected error: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module AllStak
|
|
2
|
+
module Modules
|
|
3
|
+
# Buffered structured-log ingestion. Each log is sent as its own POST.
|
|
4
|
+
class Logs
|
|
5
|
+
PATH = "/ingest/v1/logs".freeze
|
|
6
|
+
VALID_LEVELS = %w[debug info warn error fatal].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(transport, config, logger)
|
|
9
|
+
@transport = transport
|
|
10
|
+
@config = config
|
|
11
|
+
@logger = logger
|
|
12
|
+
@buffer = Transport::FlushBuffer.new(
|
|
13
|
+
name: "logs",
|
|
14
|
+
max_size: config.buffer_size,
|
|
15
|
+
interval_ms: config.flush_interval_ms,
|
|
16
|
+
flush_proc: method(:flush_batch),
|
|
17
|
+
logger: logger
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def log(level, message, service: nil, trace_id: nil, span_id: nil,
|
|
22
|
+
request_id: nil, user_id: nil, error_id: nil, metadata: nil)
|
|
23
|
+
return if @transport.disabled?
|
|
24
|
+
level = normalize_level(level)
|
|
25
|
+
|
|
26
|
+
payload = {
|
|
27
|
+
level: level,
|
|
28
|
+
message: message.to_s,
|
|
29
|
+
service: service || @config.service_name,
|
|
30
|
+
environment: @config.environment,
|
|
31
|
+
release: @config.respond_to?(:release) ? @config.release : nil,
|
|
32
|
+
traceId: trace_id,
|
|
33
|
+
spanId: span_id,
|
|
34
|
+
requestId: request_id,
|
|
35
|
+
userId: user_id,
|
|
36
|
+
errorId: error_id,
|
|
37
|
+
metadata: metadata
|
|
38
|
+
}.compact
|
|
39
|
+
@buffer.push(payload)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def debug(msg, **kw); log("debug", msg, **kw); end
|
|
43
|
+
def info(msg, **kw); log("info", msg, **kw); end
|
|
44
|
+
def warn(msg, **kw); log("warn", msg, **kw); end
|
|
45
|
+
def error(msg, **kw); log("error", msg, **kw); end
|
|
46
|
+
def fatal(msg, **kw); log("fatal", msg, **kw); end
|
|
47
|
+
|
|
48
|
+
def flush
|
|
49
|
+
@buffer.flush
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def shutdown
|
|
53
|
+
@buffer.shutdown
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def normalize_level(level)
|
|
59
|
+
lv = level.to_s.downcase
|
|
60
|
+
lv = "warn" if lv == "warning"
|
|
61
|
+
VALID_LEVELS.include?(lv) ? lv : "info"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def flush_batch(items)
|
|
65
|
+
items.each do |item|
|
|
66
|
+
begin
|
|
67
|
+
@transport.post(PATH, item)
|
|
68
|
+
rescue Transport::AllStakAuthError
|
|
69
|
+
return
|
|
70
|
+
rescue Transport::AllStakTransportError => e
|
|
71
|
+
@logger.debug("[AllStak] log transport error (discarding): #{e.message}")
|
|
72
|
+
rescue => e
|
|
73
|
+
@logger.debug("[AllStak] unexpected log error: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|