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.
@@ -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
- @release = ENV["ALLSTAK_RELEASE"]
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: client.tracing.current_trace_id,
68
+ trace_id: trace_id,
69
+ request_id: request_id,
70
+ span_id: span_id,
63
71
  error_fingerprint: error_fp
64
72
  )
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
- # Trace id — adopt incoming or mint fresh per request
24
- incoming = env["HTTP_X_ALLSTAK_TRACE_ID"] || env["HTTP_TRACEPARENT"]
25
- if incoming && !incoming.empty?
26
- client.tracing.set_trace_id(incoming)
26
+ trace_id = trace_id_from_env(env)
27
+ parent_span_id = parent_span_id_from_env(env)
28
+ request_id = env["HTTP_X_REQUEST_ID"] || env["HTTP_X_ALLSTAK_REQUEST_ID"] || SecureRandom.hex(16)
29
+ if trace_id && !trace_id.empty?
30
+ client.tracing.set_trace_id(trace_id)
27
31
  else
28
32
  client.tracing.reset_trace
29
33
  end
30
34
  trace_id = client.tracing.current_trace_id
35
+ span = client.tracing.start_span(
36
+ "http.server",
37
+ description: "#{env["REQUEST_METHOD"] || "GET"} #{env["PATH_INFO"] || "/"}",
38
+ tags: {
39
+ "http.method" => env["REQUEST_METHOD"] || "GET",
40
+ "http.route" => env["PATH_INFO"] || "/"
41
+ }
42
+ )
31
43
 
32
44
  status = 0
33
45
  headers = {}
@@ -61,18 +73,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 header for downstream trace linkage
103
- headers["X-AllStak-Trace-Id"] = trace_id if headers && !captured
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
- ip = env["REMOTE_ADDR"] || env["HTTP_X_FORWARDED_FOR"]
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)