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.
@@ -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