allstak 0.1.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +135 -0
- data/README.md +72 -240
- data/allstak.gemspec +10 -9
- data/lib/allstak/client.rb +58 -2
- data/lib/allstak/config.rb +246 -3
- data/lib/allstak/global_handler.rb +100 -0
- data/lib/allstak/integrations/net_http.rb +9 -1
- data/lib/allstak/integrations/rack.rb +54 -10
- data/lib/allstak/integrations/rails.rb +59 -0
- data/lib/allstak/integrations/sidekiq.rb +183 -0
- data/lib/allstak/modules/database.rb +4 -1
- data/lib/allstak/modules/errors.rb +84 -3
- data/lib/allstak/modules/http_monitor.rb +7 -2
- data/lib/allstak/modules/logs.rb +5 -2
- 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 +228 -0
- data/lib/allstak/transport/http_transport.rb +168 -5
- data/lib/allstak/version.rb +1 -1
- data/lib/allstak.rb +77 -1
- metadata +23 -29
data/lib/allstak/config.rb
CHANGED
|
@@ -1,17 +1,205 @@
|
|
|
1
|
+
require_relative "version"
|
|
2
|
+
|
|
1
3
|
module AllStak
|
|
4
|
+
# Runtime release detection from the local git checkout.
|
|
5
|
+
#
|
|
6
|
+
# The parsing logic is split from the actual shelling-out so it stays
|
|
7
|
+
# seamable: tests inject a fake "git runner" callable instead of relying on a
|
|
8
|
+
# real repository on disk.
|
|
9
|
+
#
|
|
10
|
+
# PRODUCTION HONESTY: a deployed artifact (packaged gem / container image)
|
|
11
|
+
# usually has no `.git` directory, so {.detect_release} returns nil there and
|
|
12
|
+
# the SDK version constant becomes the effective release. Runtime git
|
|
13
|
+
# detection mainly helps source/dev deployments running inside a checkout.
|
|
14
|
+
module GitRelease
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Default runner: shells out to the real `git` binary from the process
|
|
18
|
+
# working directory with a short timeout. Raises on any failure (git
|
|
19
|
+
# missing, no repo, timeout) so callers can treat every failure uniformly.
|
|
20
|
+
DEFAULT_RUNNER = lambda do |args|
|
|
21
|
+
require "open3"
|
|
22
|
+
require "timeout"
|
|
23
|
+
out = nil
|
|
24
|
+
Timeout.timeout(2) do
|
|
25
|
+
stdout, _stderr, status = Open3.capture3("git", *args)
|
|
26
|
+
raise "git exited #{status.exitstatus}" unless status.success?
|
|
27
|
+
out = stdout
|
|
28
|
+
end
|
|
29
|
+
out.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Best-effort release string from local git. Order:
|
|
33
|
+
# 1. `git describe --tags --always --dirty` (preferred).
|
|
34
|
+
# 2. else `git rev-parse --short HEAD`, with `-dirty` appended when
|
|
35
|
+
# `git status --porcelain` is non-empty.
|
|
36
|
+
# Fully guarded: returns nil if the runner raises or yields nothing for both
|
|
37
|
+
# strategies. Never raises.
|
|
38
|
+
def detect_release(runner = DEFAULT_RUNNER)
|
|
39
|
+
begin
|
|
40
|
+
described = runner.call(["describe", "--tags", "--always", "--dirty"]).to_s.strip
|
|
41
|
+
return described unless described.empty?
|
|
42
|
+
rescue StandardError
|
|
43
|
+
# fall through to rev-parse
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
sha = runner.call(["rev-parse", "--short", "HEAD"]).to_s.strip
|
|
48
|
+
return nil if sha.empty?
|
|
49
|
+
|
|
50
|
+
status = begin
|
|
51
|
+
runner.call(["status", "--porcelain"]).to_s
|
|
52
|
+
rescue StandardError
|
|
53
|
+
""
|
|
54
|
+
end
|
|
55
|
+
return "#{sha}-dirty" unless status.strip.empty?
|
|
56
|
+
|
|
57
|
+
sha
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
2
64
|
# SDK configuration. Populated via {AllStak.configure}.
|
|
3
65
|
class Config
|
|
66
|
+
SDK_NAME = "allstak-ruby"
|
|
67
|
+
# Single source of truth: keep the wire `sdk.version` in lockstep with
|
|
68
|
+
# the gem version declared in version.rb.
|
|
69
|
+
SDK_VERSION = AllStak::VERSION
|
|
70
|
+
|
|
4
71
|
attr_accessor :api_key, :host, :environment, :release, :service_name,
|
|
5
72
|
:flush_interval_ms, :buffer_size, :debug,
|
|
6
73
|
:connect_timeout, :read_timeout, :max_retries,
|
|
7
74
|
:capture_unhandled_exceptions, :capture_http_requests,
|
|
8
|
-
:capture_user_context, :capture_sql
|
|
75
|
+
:capture_user_context, :capture_sql,
|
|
76
|
+
# Install a process-wide `at_exit` hook that captures the
|
|
77
|
+
# exception terminating the process (global uncaught
|
|
78
|
+
# handler). Opt out by setting this false.
|
|
79
|
+
:install_at_exit_handler,
|
|
80
|
+
# Event pre-processing hook. A callable (proc/lambda) invoked
|
|
81
|
+
# once just before transport with the event Hash; returns a
|
|
82
|
+
# (possibly modified) event Hash, or nil to DROP the event.
|
|
83
|
+
# Runs for exception + message capture. Fail-open: if it
|
|
84
|
+
# raises, the original event is sent.
|
|
85
|
+
:before_send,
|
|
86
|
+
# Deterministic head-sampling for error/message events in
|
|
87
|
+
# [0.0, 1.0]. 1.0 keeps everything (default); 0.0 drops all.
|
|
88
|
+
:sample_rate,
|
|
89
|
+
# Span sampling rate in [0.0, 1.0]. nil disables span
|
|
90
|
+
# sampling entirely (every span kept, traceparent sampled
|
|
91
|
+
# flag stays "01" — the historical behavior). When set, span
|
|
92
|
+
# creation is sampled and the propagated traceparent sampled
|
|
93
|
+
# flag reflects the decision.
|
|
94
|
+
:traces_sample_rate,
|
|
95
|
+
# When true (default), and no explicit release or release env
|
|
96
|
+
# var is found, the SDK tries local git (git describe) once at
|
|
97
|
+
# init and finally falls back to the SDK version constant so
|
|
98
|
+
# release is never empty. Set false to disable the git +
|
|
99
|
+
# version-constant fallbacks (explicit + env still apply).
|
|
100
|
+
:auto_detect_release,
|
|
101
|
+
# When true (default), register the resolved release with
|
|
102
|
+
# AllStak at runtime startup without requiring CI/CD.
|
|
103
|
+
:auto_register_release,
|
|
104
|
+
# When true (default), the SDK tracks one release-health
|
|
105
|
+
# session per process: it POSTs /sessions/start on configure
|
|
106
|
+
# and /sessions/end on graceful shutdown (at_exit), and marks
|
|
107
|
+
# the session errored/crashed as events are captured. Set
|
|
108
|
+
# false to opt out entirely. Automatically disabled under a
|
|
109
|
+
# unit-test runtime.
|
|
110
|
+
:enable_auto_session_tracking,
|
|
111
|
+
# Offline/persistent event queue. When true (default), an
|
|
112
|
+
# un-deliverable PII-scrubbed envelope (network outage, retries
|
|
113
|
+
# exhausted, shutdown with events still buffered) is written to
|
|
114
|
+
# a filesystem spool and re-sent on the next SDK init. Best-
|
|
115
|
+
# effort + bounded; degrades silently to in-memory behavior
|
|
116
|
+
# when the spool dir is not writable (read-only FS, sandboxed,
|
|
117
|
+
# serverless). Set false to disable persistence entirely.
|
|
118
|
+
:enable_offline_queue,
|
|
119
|
+
# Spool directory. nil → <tmpdir>/allstak-spool. Configure to
|
|
120
|
+
# point at a durable, writable path (e.g. a mounted volume).
|
|
121
|
+
:offline_queue_dir,
|
|
122
|
+
# Spool bounds. Oldest entries are evicted first when over a
|
|
123
|
+
# limit. Sane server defaults live in EventSpool.
|
|
124
|
+
:offline_queue_max_entries,
|
|
125
|
+
:offline_queue_max_bytes,
|
|
126
|
+
:offline_queue_max_age_s,
|
|
127
|
+
# Sentry-parity PII control. When FALSE (default), value-pattern
|
|
128
|
+
# scrubbing strips email + IP addresses from free-text values
|
|
129
|
+
# before they hit the wire, and any auto-collected client IP
|
|
130
|
+
# the SDK attaches is dropped. When TRUE, the caller has opted
|
|
131
|
+
# into PII: those tier-B value scrubbers are disabled and the
|
|
132
|
+
# auto-collected client IP is allowed through. High-risk
|
|
133
|
+
# financial/identity data (credit-card numbers passing Luhn,
|
|
134
|
+
# hyphenated US SSNs) is ALWAYS scrubbed regardless of this
|
|
135
|
+
# flag, and explicitly-set user data (setUser id/email/ip) is
|
|
136
|
+
# NEVER stripped. Honors ALLSTAK_SEND_DEFAULT_PII=1/true.
|
|
137
|
+
:send_default_pii,
|
|
138
|
+
# Extra key-name denylist terms (case-insensitive substring)
|
|
139
|
+
# merged into the canonical denylist on the wire path. Extends,
|
|
140
|
+
# never narrows. Honors ALLSTAK_EXTRA_DENYLIST (comma-separated).
|
|
141
|
+
:extra_denylist,
|
|
142
|
+
# Release-tracking metadata. All optional; we auto-detect
|
|
143
|
+
# the common ones from CI env vars below.
|
|
144
|
+
:dist, :commit_sha, :branch, :platform, :sdk_name, :sdk_version
|
|
145
|
+
|
|
146
|
+
# Process-wide cache so we shell out to git at most once. `false` means
|
|
147
|
+
# "resolved to nil" (distinct from "not yet resolved").
|
|
148
|
+
@git_release_cache = nil
|
|
149
|
+
@git_release_resolved = false
|
|
150
|
+
|
|
151
|
+
class << self
|
|
152
|
+
# Resolve the git-derived release once per process and cache it.
|
|
153
|
+
def cached_git_release
|
|
154
|
+
return @git_release_cache if @git_release_resolved
|
|
155
|
+
|
|
156
|
+
@git_release_resolved = true
|
|
157
|
+
@git_release_cache =
|
|
158
|
+
begin
|
|
159
|
+
GitRelease.detect_release
|
|
160
|
+
rescue StandardError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Test seam: reset the cache between examples.
|
|
166
|
+
def reset_git_release_cache!
|
|
167
|
+
@git_release_resolved = false
|
|
168
|
+
@git_release_cache = nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Test seam: pre-seed the resolved git release so #finalize_release! can be
|
|
172
|
+
# exercised without shelling out to a real repo.
|
|
173
|
+
def seed_git_release_cache!(value)
|
|
174
|
+
@git_release_resolved = true
|
|
175
|
+
@git_release_cache = value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
9
178
|
|
|
10
179
|
def initialize
|
|
11
180
|
@api_key = ENV["ALLSTAK_API_KEY"].to_s
|
|
12
181
|
@host = ENV["ALLSTAK_HOST"] || "https://api.allstak.sa"
|
|
13
|
-
@environment = ENV["ALLSTAK_ENVIRONMENT"]
|
|
14
|
-
|
|
182
|
+
@environment = ENV["ALLSTAK_ENVIRONMENT"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "production"
|
|
183
|
+
# 1. explicit release is set via AllStak.configure after initialize.
|
|
184
|
+
# 2. env-var detection (unchanged).
|
|
185
|
+
@release = ENV["ALLSTAK_RELEASE"] ||
|
|
186
|
+
ENV["VERCEL_GIT_COMMIT_SHA"]&.slice(0, 12) ||
|
|
187
|
+
ENV["RAILWAY_GIT_COMMIT_SHA"]&.slice(0, 12) ||
|
|
188
|
+
ENV["RENDER_GIT_COMMIT"]&.slice(0, 12)
|
|
189
|
+
@auto_detect_release = true
|
|
190
|
+
@auto_register_release = true
|
|
191
|
+
@enable_auto_session_tracking = true
|
|
192
|
+
# Offline/persistent event queue: default ON for server runtimes; nil dir
|
|
193
|
+
# → <tmpdir>/allstak-spool. nil bound knobs fall back to EventSpool
|
|
194
|
+
# defaults. Honors ALLSTAK_OFFLINE_QUEUE=0/false and ALLSTAK_OFFLINE_QUEUE_DIR.
|
|
195
|
+
@enable_offline_queue = !%w[0 false no off].include?(ENV["ALLSTAK_OFFLINE_QUEUE"].to_s.strip.downcase)
|
|
196
|
+
@offline_queue_dir = ENV["ALLSTAK_OFFLINE_QUEUE_DIR"]
|
|
197
|
+
@offline_queue_max_entries = nil
|
|
198
|
+
@offline_queue_max_bytes = nil
|
|
199
|
+
@offline_queue_max_age_s = nil
|
|
200
|
+
# Sentry parity: default FALSE. Opt in via ALLSTAK_SEND_DEFAULT_PII.
|
|
201
|
+
@send_default_pii = %w[1 true yes on].include?(ENV["ALLSTAK_SEND_DEFAULT_PII"].to_s.strip.downcase)
|
|
202
|
+
@extra_denylist = parse_extra_denylist(ENV["ALLSTAK_EXTRA_DENYLIST"])
|
|
15
203
|
@service_name = ENV["ALLSTAK_SERVICE"] || "ruby-service"
|
|
16
204
|
@flush_interval_ms = 2_000
|
|
17
205
|
@buffer_size = 500
|
|
@@ -23,14 +211,69 @@ module AllStak
|
|
|
23
211
|
@capture_http_requests = true
|
|
24
212
|
@capture_user_context = true
|
|
25
213
|
@capture_sql = true
|
|
214
|
+
@install_at_exit_handler = true
|
|
215
|
+
@before_send = nil
|
|
216
|
+
@sample_rate = 1.0
|
|
217
|
+
@traces_sample_rate = nil
|
|
218
|
+
# Release metadata
|
|
219
|
+
@platform = "ruby"
|
|
220
|
+
@sdk_name = SDK_NAME
|
|
221
|
+
@sdk_version = SDK_VERSION
|
|
222
|
+
@commit_sha = ENV["ALLSTAK_COMMIT_SHA"] || ENV["GIT_COMMIT"] || ENV["VERCEL_GIT_COMMIT_SHA"] ||
|
|
223
|
+
ENV["RAILWAY_GIT_COMMIT_SHA"] || ENV["RENDER_GIT_COMMIT"]
|
|
224
|
+
@branch = ENV["ALLSTAK_BRANCH"] || ENV["GIT_BRANCH"] || ENV["VERCEL_GIT_COMMIT_REF"] ||
|
|
225
|
+
ENV["RAILWAY_GIT_BRANCH"]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Resolve the release after explicit user config has been applied (called
|
|
229
|
+
# from {AllStak.configure} once the user's block has run). Resolution order:
|
|
230
|
+
# 1. explicit @release (set by the user) — kept as-is.
|
|
231
|
+
# 2. release env var — already applied in #initialize.
|
|
232
|
+
# 3. local git (cached, guarded) when auto_detect_release.
|
|
233
|
+
# 4. SDK version constant when auto_detect_release.
|
|
234
|
+
# Never raises and never overwrites a non-empty release.
|
|
235
|
+
def finalize_release!
|
|
236
|
+
return self unless @release.nil? || @release.to_s.empty?
|
|
237
|
+
return self unless @auto_detect_release
|
|
238
|
+
|
|
239
|
+
@release = AllStak::Config.cached_git_release
|
|
240
|
+
@release = SDK_VERSION if @release.nil? || @release.to_s.empty?
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Release-tracking tags merged into every event payload's metadata.
|
|
245
|
+
def release_tags
|
|
246
|
+
tags = {}
|
|
247
|
+
tags["sdk.name"] = @sdk_name if @sdk_name
|
|
248
|
+
tags["sdk.version"] = @sdk_version if @sdk_version
|
|
249
|
+
tags["platform"] = @platform if @platform
|
|
250
|
+
tags["dist"] = @dist if @dist
|
|
251
|
+
tags["commit.sha"] = @commit_sha if @commit_sha
|
|
252
|
+
tags["commit.branch"] = @branch if @branch
|
|
253
|
+
tags
|
|
26
254
|
end
|
|
27
255
|
|
|
28
256
|
def valid?
|
|
29
257
|
!@api_key.to_s.empty?
|
|
30
258
|
end
|
|
31
259
|
|
|
260
|
+
# Idiomatic predicate for the PII toggle (coerces truthy config values).
|
|
261
|
+
def send_default_pii?
|
|
262
|
+
@send_default_pii ? true : false
|
|
263
|
+
end
|
|
264
|
+
|
|
32
265
|
def host=(value)
|
|
33
266
|
@host = value.to_s.sub(%r{/+\z}, "")
|
|
34
267
|
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
# Parse a comma/space-separated env list into a clean array of terms.
|
|
272
|
+
# Returns nil when empty so the default canonical denylist is unaffected.
|
|
273
|
+
def parse_extra_denylist(raw)
|
|
274
|
+
return nil if raw.nil?
|
|
275
|
+
terms = raw.to_s.split(/[,\s]+/).map(&:strip).reject(&:empty?)
|
|
276
|
+
terms.empty? ? nil : terms
|
|
277
|
+
end
|
|
35
278
|
end
|
|
36
279
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module AllStak
|
|
2
|
+
# Global uncaught-exception capture.
|
|
3
|
+
#
|
|
4
|
+
# Ruby has no first-class "uncaught exception" callback, but it runs
|
|
5
|
+
# `at_exit` blocks during interpreter teardown — and while they run, `$!`
|
|
6
|
+
# still holds the exception that is killing the process (if any). Inspecting
|
|
7
|
+
# `$!` from inside an `at_exit` block is the idiomatic way to catch a
|
|
8
|
+
# top-level unhandled exception. We capture it, mark it unhandled
|
|
9
|
+
# (mechanism handled=false), and do a best-effort synchronous flush before
|
|
10
|
+
# the process dies.
|
|
11
|
+
#
|
|
12
|
+
# We are deliberately conservative about WHAT counts as an unhandled
|
|
13
|
+
# termination so a clean exit (or a normal `exit`/`exit!`) is never reported
|
|
14
|
+
# as an error:
|
|
15
|
+
# - `$!` must be present and be an Exception.
|
|
16
|
+
# - SystemExit is treated as unhandled only when its status is non-zero
|
|
17
|
+
# (i.e. `exit(1)` / `abort`), never `exit(0)` / `exit` (clean exit).
|
|
18
|
+
# - SignalException (e.g. Ctrl-C / SIGINT) is ignored.
|
|
19
|
+
module GlobalHandler
|
|
20
|
+
MECHANISM = "at_exit".freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Install the process-wide at_exit hook. Idempotent: the actual at_exit
|
|
25
|
+
# block is registered exactly once per process, regardless of how many
|
|
26
|
+
# times this is called (reconfigure, multiple configure calls, etc.).
|
|
27
|
+
# The block reads the live client at exit time, so reconfiguration is
|
|
28
|
+
# honored without re-registering.
|
|
29
|
+
def install!(logger = nil)
|
|
30
|
+
return if @installed
|
|
31
|
+
@installed = true
|
|
32
|
+
logger&.debug("[AllStak] installing at_exit unhandled-exception handler")
|
|
33
|
+
at_exit { run_at_exit($!) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def installed?
|
|
37
|
+
@installed == true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Test seam: forget that we installed (does NOT unregister the real
|
|
41
|
+
# at_exit block — Ruby has no API for that — but lets a test drive
|
|
42
|
+
# install!/idempotency logic deterministically).
|
|
43
|
+
def reset!
|
|
44
|
+
@installed = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The body of the at_exit hook, factored out so it is directly unit
|
|
48
|
+
# testable without actually terminating the process. `exc` is whatever
|
|
49
|
+
# `$!` held at exit time.
|
|
50
|
+
def run_at_exit(exc)
|
|
51
|
+
return unless AllStak.initialized?
|
|
52
|
+
return unless unhandled_termination?(exc)
|
|
53
|
+
capture_unhandled(exc)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Decide whether `exc` represents a genuine unhandled termination that we
|
|
57
|
+
# should report, vs. a clean/expected exit we must ignore.
|
|
58
|
+
def unhandled_termination?(exc)
|
|
59
|
+
return false unless exc.is_a?(Exception)
|
|
60
|
+
# Ignore Ctrl-C / signal-driven teardown.
|
|
61
|
+
return false if exc.is_a?(SignalException)
|
|
62
|
+
# `exit`/`exit(0)` raise SystemExit with success? == true: clean exit.
|
|
63
|
+
if exc.is_a?(SystemExit)
|
|
64
|
+
return exc.respond_to?(:success?) ? !exc.success? : exc.status.to_i != 0
|
|
65
|
+
end
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Capture an exception as a global, unhandled event and flush
|
|
70
|
+
# synchronously. Safe to call directly as a documented integration point:
|
|
71
|
+
#
|
|
72
|
+
# begin
|
|
73
|
+
# run_worker
|
|
74
|
+
# rescue => e
|
|
75
|
+
# AllStak::GlobalHandler.capture_unhandled(e)
|
|
76
|
+
# raise
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# Also surfaced as {AllStak.capture_unhandled}.
|
|
80
|
+
def capture_unhandled(exc)
|
|
81
|
+
return nil unless AllStak.initialized?
|
|
82
|
+
client = AllStak.client
|
|
83
|
+
begin
|
|
84
|
+
client.capture_exception(
|
|
85
|
+
exc,
|
|
86
|
+
metadata: {
|
|
87
|
+
"mechanism" => MECHANISM,
|
|
88
|
+
"handled" => false
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
rescue => e
|
|
92
|
+
AllStak.logger&.debug("[AllStak] at_exit capture failed: #{e.class}: #{e.message}")
|
|
93
|
+
ensure
|
|
94
|
+
# Best-effort synchronous flush so buffered telemetry leaves the
|
|
95
|
+
# process before it dies. Never raise out of an at_exit hook.
|
|
96
|
+
client.flush rescue nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
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,7 +65,9 @@ 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
|
)
|
|
65
73
|
rescue
|
|
@@ -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,24 @@ 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")
|
|
66
83
|
rescue => err
|
|
67
84
|
# never raise into host
|
|
68
85
|
config.debug && warn("[AllStak] rack request capture failed: #{err.message}")
|
|
69
86
|
end
|
|
70
87
|
end
|
|
88
|
+
span.finish(status.to_i >= 500 || captured ? "error" : "ok") unless span.finished?
|
|
71
89
|
|
|
72
90
|
# Exception capture
|
|
73
91
|
if captured && config.capture_unhandled_exceptions
|
|
74
92
|
begin
|
|
75
|
-
user_ctx = config.capture_user_context ? build_user_context(env) : nil
|
|
93
|
+
user_ctx = config.capture_user_context ? build_user_context(env, config) : nil
|
|
76
94
|
req_ctx = AllStak::Models::RequestContext.new(
|
|
77
95
|
method: env["REQUEST_METHOD"],
|
|
78
96
|
path: env["PATH_INFO"],
|
|
@@ -85,7 +103,8 @@ module AllStak
|
|
|
85
103
|
"http.path" => env["PATH_INFO"],
|
|
86
104
|
"http.host" => env["HTTP_HOST"],
|
|
87
105
|
"http.status" => status.to_i == 0 ? 500 : status.to_i,
|
|
88
|
-
"traceId" => trace_id
|
|
106
|
+
"traceId" => trace_id,
|
|
107
|
+
"requestId" => request_id
|
|
89
108
|
}
|
|
90
109
|
client.errors.capture_exception(
|
|
91
110
|
captured,
|
|
@@ -99,8 +118,14 @@ module AllStak
|
|
|
99
118
|
end
|
|
100
119
|
end
|
|
101
120
|
|
|
102
|
-
# Best-effort response
|
|
103
|
-
|
|
121
|
+
# Best-effort response headers for downstream trace linkage.
|
|
122
|
+
AllStak::Propagation.apply_headers(
|
|
123
|
+
headers,
|
|
124
|
+
trace_id: trace_id,
|
|
125
|
+
request_id: request_id,
|
|
126
|
+
span_id: span.span_id,
|
|
127
|
+
sampled: client.tracing.current_trace_sampled?
|
|
128
|
+
) if headers && !captured
|
|
104
129
|
end
|
|
105
130
|
|
|
106
131
|
[status, headers, body]
|
|
@@ -112,6 +137,20 @@ module AllStak
|
|
|
112
137
|
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
113
138
|
end
|
|
114
139
|
|
|
140
|
+
def trace_id_from_env(env)
|
|
141
|
+
traceparent = env["HTTP_TRACEPARENT"].to_s
|
|
142
|
+
parts = traceparent.split("-")
|
|
143
|
+
return parts[1] if parts.length >= 2 && parts[1].length == 32
|
|
144
|
+
env["HTTP_X_ALLSTAK_TRACE_ID"] || env["HTTP_X_TRACE_ID"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parent_span_id_from_env(env)
|
|
148
|
+
traceparent = env["HTTP_TRACEPARENT"].to_s
|
|
149
|
+
parts = traceparent.split("-")
|
|
150
|
+
return parts[2] if parts.length >= 3 && parts[2].length == 16
|
|
151
|
+
env["HTTP_X_ALLSTAK_SPAN_ID"] || env["HTTP_X_SPAN_ID"]
|
|
152
|
+
end
|
|
153
|
+
|
|
115
154
|
def extract_user_id(env)
|
|
116
155
|
# Rack-standard: env["warden"]? env["rack.session"]?
|
|
117
156
|
# Apps can set env["allstak.user_id"] directly.
|
|
@@ -123,11 +162,16 @@ module AllStak
|
|
|
123
162
|
nil
|
|
124
163
|
end
|
|
125
164
|
|
|
126
|
-
def build_user_context(env)
|
|
165
|
+
def build_user_context(env, config = nil)
|
|
127
166
|
id = extract_user_id(env)
|
|
128
167
|
email = env["allstak.user_email"]
|
|
129
168
|
return nil if id.nil? && email.nil?
|
|
130
|
-
|
|
169
|
+
# The client IP here is AUTO-collected by the middleware (not set
|
|
170
|
+
# explicitly by the app via setUser). Sentry parity: drop it unless
|
|
171
|
+
# the caller opted into PII via send_default_pii. Guarded so a nil/old
|
|
172
|
+
# config defaults to the privacy-preserving behavior.
|
|
173
|
+
send_pii = config.respond_to?(:send_default_pii?) ? config.send_default_pii? : false
|
|
174
|
+
ip = send_pii ? (env["REMOTE_ADDR"] || env["HTTP_X_FORWARDED_FOR"]) : nil
|
|
131
175
|
AllStak::Models::UserContext.new(id: id, email: email, ip: ip)
|
|
132
176
|
end
|
|
133
177
|
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)
|