alplus-ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +89 -0
- data/lib/alplus/active_job.rb +78 -0
- data/lib/alplus/client.rb +228 -0
- data/lib/alplus/configuration.rb +93 -0
- data/lib/alplus/dedup.rb +110 -0
- data/lib/alplus/envelope.rb +244 -0
- data/lib/alplus/heartbeat.rb +90 -0
- data/lib/alplus/id.rb +47 -0
- data/lib/alplus/logger_breadcrumbs.rb +64 -0
- data/lib/alplus/notifications_subscriber.rb +107 -0
- data/lib/alplus/pending_window.rb +106 -0
- data/lib/alplus/rack_middleware.rb +127 -0
- data/lib/alplus/rails_error_subscriber.rb +25 -0
- data/lib/alplus/railtie.rb +80 -0
- data/lib/alplus/retry.rb +86 -0
- data/lib/alplus/scope.rb +104 -0
- data/lib/alplus/scrubber.rb +67 -0
- data/lib/alplus/session.rb +81 -0
- data/lib/alplus/sidekiq.rb +76 -0
- data/lib/alplus/stack.rb +124 -0
- data/lib/alplus/testing.rb +28 -0
- data/lib/alplus/transport.rb +104 -0
- data/lib/alplus/version.rb +13 -0
- data/lib/alplus/worker.rb +106 -0
- data/lib/alplus.rb +184 -0
- metadata +201 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Alplus
|
|
6
|
+
# Rack middleware: captures any exception that propagates up through the
|
|
7
|
+
# app, then re-raises so the host's own error page / handler still runs
|
|
8
|
+
# unchanged. Installed automatically by the Rails railtie; usable directly
|
|
9
|
+
# in a plain Rack app (`use Alplus::RackMiddleware`).
|
|
10
|
+
#
|
|
11
|
+
# Also resets `Scope` to a fresh, empty one for the duration of the
|
|
12
|
+
# request (issue #17): a thread-pool server (Puma, Passenger) reuses OS
|
|
13
|
+
# threads across requests, so without this a `set_user`/`set_tag` call in
|
|
14
|
+
# request A would otherwise still be visible to request B if it happens
|
|
15
|
+
# to land on the same thread.
|
|
16
|
+
#
|
|
17
|
+
# Session lifecycle (issue #12): opens a fresh request-scoped `Session`
|
|
18
|
+
# (`Session.with_clean_session`, the same reused-thread guard as `Scope`
|
|
19
|
+
# above) and closes it (`Alplus.close_session`) once `@app.call` returns
|
|
20
|
+
# OR raises. Unlike `sdks/elixir/lib/alplus_sdk/plug.ex` (which cannot
|
|
21
|
+
# rescue a downstream plug's exception — see that module's moduledoc),
|
|
22
|
+
# Rack middleware genuinely wraps the rest of the stack in one method
|
|
23
|
+
# call, so `rescue`/`ensure` here is the complete, reliable crash signal:
|
|
24
|
+
# no separate telemetry hook is needed.
|
|
25
|
+
class RackMiddleware
|
|
26
|
+
def initialize(app)
|
|
27
|
+
@app = app
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
Scope.with_clean_scope { Session.with_clean_session { call_app(env) } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def call_app(env)
|
|
37
|
+
attach_request_context(env)
|
|
38
|
+
@app.call(env)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
# StandardError only, deliberately: SystemExit/SignalException/
|
|
41
|
+
# NoMemoryError are process-control exceptions, not app errors, and
|
|
42
|
+
# must propagate untouched. `mark_crashed` runs BEFORE
|
|
43
|
+
# `capture_exception` below: that call would otherwise mark the
|
|
44
|
+
# session merely `:errored` (any `"error"`-level capture does), and
|
|
45
|
+
# `:crashed` must win regardless of call order — `Session`'s
|
|
46
|
+
# severity ordering makes this safe either way.
|
|
47
|
+
Session.current&.mark_crashed
|
|
48
|
+
Alplus.capture_exception(e, mechanism: "rack.middleware")
|
|
49
|
+
raise
|
|
50
|
+
ensure
|
|
51
|
+
Alplus.close_session
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Response headers a triaging dev actually reads. Cookie and
|
|
55
|
+
# Authorization are deliberately absent — they are secrets, not
|
|
56
|
+
# diagnostics, and key-based scrubbing must not be the only line of
|
|
57
|
+
# defense against them.
|
|
58
|
+
HEADER_ALLOWLIST = {
|
|
59
|
+
"HTTP_USER_AGENT" => "User-Agent",
|
|
60
|
+
"HTTP_REFERER" => "Referer",
|
|
61
|
+
"HTTP_ACCEPT" => "Accept",
|
|
62
|
+
"CONTENT_TYPE" => "Content-Type",
|
|
63
|
+
"HTTP_X_REQUEST_ID" => "X-Request-Id"
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
# Serialized params beyond this are replaced by a truncation marker so
|
|
67
|
+
# one giant upload form cannot push the whole `contexts` object over
|
|
68
|
+
# `Envelope::MAX_CONTEXT_CHARS` (which would replace ALL contexts).
|
|
69
|
+
MAX_PARAMS_CHARS = 4_096
|
|
70
|
+
|
|
71
|
+
# Attaches `contexts.request` (method, url without query string, parsed
|
|
72
|
+
# params, allowlisted headers) to the request's fresh Scope, so every
|
|
73
|
+
# capture during this request carries it. Params go through the built-in
|
|
74
|
+
# `Scrubber` at capture time like any other context value. The raw query
|
|
75
|
+
# string is never attached: a raw string cannot be key-scrubbed; the
|
|
76
|
+
# parsed params hash can. Never raises — request context is diagnostics,
|
|
77
|
+
# not a reason to break the request.
|
|
78
|
+
def attach_request_context(env)
|
|
79
|
+
request = ::Rack::Request.new(env)
|
|
80
|
+
|
|
81
|
+
context = {
|
|
82
|
+
method: request.request_method,
|
|
83
|
+
url: "#{request.base_url}#{request.path}",
|
|
84
|
+
params: bounded_params(request),
|
|
85
|
+
headers: request_headers(env)
|
|
86
|
+
}.compact
|
|
87
|
+
|
|
88
|
+
Scope.current.set_context("request", context)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bounded_params(request)
|
|
94
|
+
params = query_params(request).merge(form_params(request))
|
|
95
|
+
return nil if params.empty?
|
|
96
|
+
return { "_truncated" => true } if JSON.generate(params).bytesize > MAX_PARAMS_CHARS
|
|
97
|
+
|
|
98
|
+
params
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def query_params(request)
|
|
104
|
+
request.GET || {}
|
|
105
|
+
rescue StandardError
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Form-encoded bodies only; `Rack::Request#POST` rewinds the input, so
|
|
110
|
+
# the downstream app still reads the full body. A JSON body is not
|
|
111
|
+
# parsed here — parsing it would mean buffering and re-encoding the
|
|
112
|
+
# body on every request just in case an error happens later.
|
|
113
|
+
def form_params(request)
|
|
114
|
+
request.form_data? ? request.POST : {}
|
|
115
|
+
rescue StandardError
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request_headers(env)
|
|
120
|
+
headers = HEADER_ALLOWLIST.each_with_object({}) do |(rack_key, header), out|
|
|
121
|
+
value = env[rack_key]
|
|
122
|
+
out[header] = value.to_s if value
|
|
123
|
+
end
|
|
124
|
+
headers.empty? ? nil : headers
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Rails 7+ error reporter subscriber (`Rails.error.subscribe`). Forwards
|
|
5
|
+
# only HANDLED reports (`Rails.error.handle`/`Rails.error.record`, or any
|
|
6
|
+
# library that reports through `Rails.error`) — an UNHANDLED exception
|
|
7
|
+
# already reaches `Alplus::RackMiddleware` (installed directly around the
|
|
8
|
+
# app, inside `ActionDispatch::ShowExceptions`) as it propagates up the
|
|
9
|
+
# middleware stack. Subscribing to `handled: false` here too would
|
|
10
|
+
# double-report the same exception on Rails versions that also route
|
|
11
|
+
# unhandled request exceptions through the error reporter.
|
|
12
|
+
class RailsErrorSubscriber
|
|
13
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
14
|
+
return unless handled
|
|
15
|
+
|
|
16
|
+
Alplus.capture_exception(error, level: severity_to_level(severity), context: context, mechanism: "rails.error_reporter")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def severity_to_level(severity)
|
|
22
|
+
{ error: "error", warning: "warning", info: "info" }.fetch(severity, "error").to_s
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails_error_subscriber"
|
|
4
|
+
require_relative "notifications_subscriber"
|
|
5
|
+
|
|
6
|
+
module Alplus
|
|
7
|
+
# Installs `Alplus::RackMiddleware` and auto-detects the app root from
|
|
8
|
+
# Rails config, so a Rails app gets automatic unhandled-exception capture
|
|
9
|
+
# with zero explicit wiring beyond setting the ingest key (issue #14
|
|
10
|
+
# story 2/12).
|
|
11
|
+
#
|
|
12
|
+
# Middleware position matters, and there are TWO exception renderers to
|
|
13
|
+
# get inside of, not one:
|
|
14
|
+
#
|
|
15
|
+
# * `ActionDispatch::ShowExceptions` (outer) renders the public error
|
|
16
|
+
# page in production.
|
|
17
|
+
# * `ActionDispatch::DebugExceptions` (inner, closer to the app) renders
|
|
18
|
+
# the developer error page in development — and, crucially, it does
|
|
19
|
+
# NOT re-raise. In development it rescues the exception and returns the
|
|
20
|
+
# debug page, so any middleware OUTSIDE it never sees the exception.
|
|
21
|
+
#
|
|
22
|
+
# Inserting after `ShowExceptions` (the original fix) works in production
|
|
23
|
+
# — there `DebugExceptions` re-raises and our middleware, sitting between
|
|
24
|
+
# the two, catches the re-raised exception. But in DEVELOPMENT
|
|
25
|
+
# `DebugExceptions` swallows first, so unhandled web errors were lost
|
|
26
|
+
# (verified against a real Rails 8 app, 2026-08-17).
|
|
27
|
+
#
|
|
28
|
+
# `insert_after ActionDispatch::DebugExceptions` places `RackMiddleware`
|
|
29
|
+
# inside BOTH renderers — the innermost exception boundary, matching
|
|
30
|
+
# `Sentry::Rails::CaptureExceptions`. The app's exception now reaches our
|
|
31
|
+
# `rescue`/re-raise before either renderer runs, in every environment.
|
|
32
|
+
class Railtie < ::Rails::Railtie
|
|
33
|
+
initializer "alplus.configure" do |app|
|
|
34
|
+
config = Alplus.configuration
|
|
35
|
+
config.app_dirs = [::Rails.root.to_s] if config.app_dirs.empty? && ::Rails.respond_to?(:root) && ::Rails.root
|
|
36
|
+
|
|
37
|
+
if defined?(::ActionDispatch::DebugExceptions) && app.middleware.respond_to?(:insert_after)
|
|
38
|
+
app.middleware.insert_after ::ActionDispatch::DebugExceptions, Alplus::RackMiddleware
|
|
39
|
+
else
|
|
40
|
+
app.middleware.insert 0, Alplus::RackMiddleware
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
44
|
+
::Rails.error.subscribe(Alplus::RailsErrorSubscriber.new)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Alplus::NotificationsSubscriber.install!
|
|
48
|
+
|
|
49
|
+
# Log-line breadcrumbs (issue #47). `config.after_initialize` rather
|
|
50
|
+
# than here: `Rails.logger` is finalized (BroadcastLogger assembled,
|
|
51
|
+
# taggers applied) only after the framework initializers run.
|
|
52
|
+
app.config.after_initialize do
|
|
53
|
+
Alplus::LoggerBreadcrumbs.attach(::Rails.logger) if ::Rails.logger
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Install the optional job integrations HERE, not at gem-require time
|
|
57
|
+
# in `alplus.rb`. By the time this initializer runs, Bundler has
|
|
58
|
+
# loaded every gem and Rails has booted its frameworks, so require
|
|
59
|
+
# order no longer matters. In a real Rails app `ActiveJob::Base`
|
|
60
|
+
# autoloads only after boot, so the `defined?` check in `alplus.rb`
|
|
61
|
+
# is false when the gem loads and would silently skip the install.
|
|
62
|
+
#
|
|
63
|
+
# `install!` is idempotent, so overlapping with `alplus.rb`'s
|
|
64
|
+
# non-Rails fallback path is safe.
|
|
65
|
+
if defined?(::Sidekiq)
|
|
66
|
+
require_relative "sidekiq"
|
|
67
|
+
Alplus::Sidekiq.install!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:on_load)
|
|
71
|
+
# Fires whenever `ActiveJob::Base` loads, regardless of gem require
|
|
72
|
+
# order relative to `alplus`.
|
|
73
|
+
::ActiveSupport.on_load(:active_job) do
|
|
74
|
+
require_relative "active_job"
|
|
75
|
+
Alplus::ActiveJob.install!
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/alplus/retry.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Shared retry/backoff loop for both `Transport` (`POST /e/errors`) and
|
|
5
|
+
# `Heartbeat` (`POST /h/:token`) — issue #16 explicitly asks the two share
|
|
6
|
+
# this, unlike the JS SDK's `transport.ts`/`heartbeat.ts`, which duplicate
|
|
7
|
+
# it deliberately because heartbeat there already shipped and touching it
|
|
8
|
+
# was the higher-risk move. Both Ruby callers land in this change
|
|
9
|
+
# together, so that risk does not apply here.
|
|
10
|
+
#
|
|
11
|
+
# Constants (3 attempts, 500ms jittered exponential base, 30s Retry-After
|
|
12
|
+
# cap) mirror `packages/sdk/src/core/observe/transport.ts` byte-for-byte
|
|
13
|
+
# so all SDKs back off the same way against the same ingest endpoint.
|
|
14
|
+
module Retry
|
|
15
|
+
MAX_ATTEMPTS = 3
|
|
16
|
+
BACKOFF_BASE_SECONDS = 0.5
|
|
17
|
+
BACKOFF_JITTER = 0.5
|
|
18
|
+
MAX_RETRY_AFTER_SECONDS = 30
|
|
19
|
+
# 400 (malformed request) and 401/403 (bad/scopeless key) and 404
|
|
20
|
+
# (unrecognized route/token) can't be fixed by retrying.
|
|
21
|
+
PERMANENT_STATUSES = [400, 401, 403, 404].freeze
|
|
22
|
+
|
|
23
|
+
Result = Struct.new(:outcome, :response, :error, keyword_init: true) do
|
|
24
|
+
def sent?
|
|
25
|
+
outcome == :sent
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Calls the block up to `max_attempts` times (default `MAX_ATTEMPTS`).
|
|
32
|
+
# The block must return a `Net::HTTPResponse` or raise. Sleeps between
|
|
33
|
+
# attempts via `sleeper` (injectable so specs never wait on real
|
|
34
|
+
# wall-clock time). A 429's `Retry-After` is capped at
|
|
35
|
+
# `max_retry_after_seconds` (default `MAX_RETRY_AFTER_SECONDS`) —
|
|
36
|
+
# `Heartbeat` passes a much lower cap so a caller pinging synchronously
|
|
37
|
+
# can never be made to block anywhere near the server's full 30s cap.
|
|
38
|
+
# Never raises: a block error on the final attempt is captured on the
|
|
39
|
+
# returned `Result`, not re-raised.
|
|
40
|
+
def perform(sleeper: method(:sleep), max_attempts: MAX_ATTEMPTS, max_retry_after_seconds: MAX_RETRY_AFTER_SECONDS)
|
|
41
|
+
result = nil
|
|
42
|
+
|
|
43
|
+
max_attempts.times do |i|
|
|
44
|
+
attempt = i + 1
|
|
45
|
+
begin
|
|
46
|
+
response = yield(attempt)
|
|
47
|
+
|
|
48
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
49
|
+
return Result.new(outcome: :sent, response: response)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
code = response.code.to_i
|
|
53
|
+
if PERMANENT_STATUSES.include?(code)
|
|
54
|
+
return Result.new(outcome: :permanent, response: response)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result = Result.new(outcome: :exhausted, response: response)
|
|
58
|
+
unless attempt == max_attempts
|
|
59
|
+
sleeper.call(code == 429 ? (retry_after_seconds(response, max_retry_after_seconds) || backoff_seconds(attempt)) : backoff_seconds(attempt))
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
result = Result.new(outcome: :exhausted, error: e)
|
|
63
|
+
sleeper.call(backoff_seconds(attempt)) unless attempt == max_attempts
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def backoff_seconds(attempt)
|
|
71
|
+
exponential = BACKOFF_BASE_SECONDS * (2**(attempt - 1))
|
|
72
|
+
jitter_factor = (1 - BACKOFF_JITTER) + (rand * 2 * BACKOFF_JITTER)
|
|
73
|
+
exponential * jitter_factor
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def retry_after_seconds(response, max_seconds = MAX_RETRY_AFTER_SECONDS)
|
|
77
|
+
value = response["Retry-After"]
|
|
78
|
+
return nil if value.nil? || value.strip.empty?
|
|
79
|
+
|
|
80
|
+
seconds = Float(value, exception: false)
|
|
81
|
+
return nil if seconds.nil? || seconds.negative?
|
|
82
|
+
|
|
83
|
+
[seconds, max_seconds].min
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/alplus/scope.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Request-scoped ambient scope (issue #17): the server analog of the JS
|
|
5
|
+
# SDK's browser `withScope`. `Alplus.set_user`/`set_tag`/`set_context`/
|
|
6
|
+
# `add_breadcrumb` write to a scope that lives on `Thread.current`
|
|
7
|
+
# (fiber-local storage in Ruby -- see `Thread#[]`), so a value set once at
|
|
8
|
+
# the top of a request (`RackMiddleware`) applies to every capture inside
|
|
9
|
+
# that request without threading it through every call site, and never
|
|
10
|
+
# bleeds into a different request handled on a reused thread-pool thread.
|
|
11
|
+
class Scope
|
|
12
|
+
THREAD_KEY = :alplus_scope
|
|
13
|
+
private_constant :THREAD_KEY
|
|
14
|
+
|
|
15
|
+
# Server ceiling (`Envelope::SERVER_MAX_BREADCRUMBS`) -- kept as a
|
|
16
|
+
# literal here rather than requiring `envelope` first (`Scope` loads
|
|
17
|
+
# before `Envelope` in `alplus.rb`), and re-asserted by
|
|
18
|
+
# `Envelope.cap_breadcrumbs` regardless.
|
|
19
|
+
MAX_BREADCRUMBS = 100
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# The current thread/fiber's scope, lazily created.
|
|
23
|
+
def current
|
|
24
|
+
Thread.current[THREAD_KEY] ||= new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Replaces the current thread/fiber's scope with a fresh, empty one
|
|
28
|
+
# and yields; restores whatever scope (if any) was active before,
|
|
29
|
+
# even if the block raises. `RackMiddleware` wraps each request in
|
|
30
|
+
# this so scope set during request A never leaks into request B on a
|
|
31
|
+
# reused thread-pool thread.
|
|
32
|
+
def with_clean_scope
|
|
33
|
+
previous = Thread.current[THREAD_KEY]
|
|
34
|
+
Thread.current[THREAD_KEY] = new
|
|
35
|
+
yield
|
|
36
|
+
ensure
|
|
37
|
+
Thread.current[THREAD_KEY] = previous
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :user, :tags, :contexts, :breadcrumbs
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@user = nil
|
|
45
|
+
@tags = {}
|
|
46
|
+
@contexts = {}
|
|
47
|
+
@breadcrumbs = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def set_user(user)
|
|
51
|
+
@user = user
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set_tag(key, value)
|
|
55
|
+
@tags[key.to_s] = value.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def set_context(name, data)
|
|
59
|
+
@contexts[name.to_s] = data
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `breadcrumb` accepts the same keys as the wire shape
|
|
63
|
+
# (`message`/`category`/`level`/`data`/`ts`); `ts` defaults to now.
|
|
64
|
+
# Bounded ring buffer: the oldest breadcrumb is dropped once the buffer
|
|
65
|
+
# is at `MAX_BREADCRUMBS`.
|
|
66
|
+
def add_breadcrumb(message: nil, category: nil, level: nil, data: nil, ts: nil)
|
|
67
|
+
crumb = { message: message, category: category, level: level, data: data, ts: ts || Time.now.utc.iso8601 }.compact
|
|
68
|
+
@breadcrumbs << crumb
|
|
69
|
+
@breadcrumbs.shift while @breadcrumbs.length > MAX_BREADCRUMBS
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Immutable snapshot handed to `Client` at capture time — mirrors the
|
|
73
|
+
# JS SDK's `ScopeSnapshot` (`scope.ts`).
|
|
74
|
+
def snapshot
|
|
75
|
+
{ user: @user, tags: @tags.dup, contexts: @contexts.dup, breadcrumbs: @breadcrumbs.dup }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Merges an ambient `Scope#snapshot` with per-capture overrides. Mirrors
|
|
80
|
+
# `packages/sdk/src/core/observe/scope.ts`'s `mergeScope`: an explicit
|
|
81
|
+
# per-call `user:` wins outright over the ambient one; `tags`/`contexts`
|
|
82
|
+
# shallow-merge with the override's keys winning on collision;
|
|
83
|
+
# breadcrumbs concatenate (ambient trail first, then any one-off
|
|
84
|
+
# breadcrumbs passed for this one call).
|
|
85
|
+
#
|
|
86
|
+
# `user:` distinguishes "not given" from "explicitly `nil`" via the
|
|
87
|
+
# `Alplus::UNSET` sentinel default, mirroring the JS SDK's
|
|
88
|
+
# `overrides.user !== undefined` check: `Client` passes `Alplus::UNSET`
|
|
89
|
+
# through when its own `user:` param wasn't given by the caller, so a
|
|
90
|
+
# caller CAN pass `user: nil` to clear the ambient user for one capture
|
|
91
|
+
# rather than always falling back to it.
|
|
92
|
+
module ScopeMerge
|
|
93
|
+
module_function
|
|
94
|
+
|
|
95
|
+
def merge(ambient:, user:, tags:, contexts:, breadcrumbs:)
|
|
96
|
+
{
|
|
97
|
+
user: user.equal?(Alplus::UNSET) ? ambient[:user] : user,
|
|
98
|
+
tags: ambient[:tags].merge(tags || {}),
|
|
99
|
+
contexts: ambient[:contexts].merge(contexts || {}),
|
|
100
|
+
breadcrumbs: ambient[:breadcrumbs] + (breadcrumbs || [])
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Default PII scrubbing applied to every captured item BEFORE
|
|
5
|
+
# `config.before_send` runs (mirrors Sentry's built-in data scrubber).
|
|
6
|
+
# Deep-walks `context`/`contexts`/`tags`/`user` (nested hashes and
|
|
7
|
+
# arrays) and replaces any value whose key matches `config.scrub_fields`
|
|
8
|
+
# (case-insensitive substring match, so `"user_password"` is caught by
|
|
9
|
+
# the `"password"` entry) with `"[FILTERED]"`.
|
|
10
|
+
#
|
|
11
|
+
# Runs against the already-built wire item hash (post `Envelope.*_item`),
|
|
12
|
+
# not the raw call-site args -- one scrub point regardless of whether the
|
|
13
|
+
# value came from an explicit `context:`/`tags:`/`user:` argument or the
|
|
14
|
+
# ambient `Scope`.
|
|
15
|
+
module Scrubber
|
|
16
|
+
DEFAULT_SCRUB_FIELDS = %w[
|
|
17
|
+
password passwd secret token authorization api_key apikey
|
|
18
|
+
access_token cookie csrf ssn credit_card card_number
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
REDACTED = "[FILTERED]"
|
|
22
|
+
# `breadcrumbs` is included so structured secrets in a crumb's `data`
|
|
23
|
+
# hash (and any secret-keyed value an integration adds) are redacted.
|
|
24
|
+
# A crumb's free-text `message` is NOT value-scrubbed -- key-based
|
|
25
|
+
# scrubbing cannot see into an arbitrary string, same as Sentry. Do
|
|
26
|
+
# not put a raw secret in a breadcrumb message.
|
|
27
|
+
SCRUBBED_KEYS = %i[context contexts tags user breadcrumbs].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Returns a new item hash with sensitive values redacted. Never raises:
|
|
32
|
+
# an internal error returns the item unscrubbed rather than drop a real
|
|
33
|
+
# event over a scrubbing bug -- capture must not be less reliable than
|
|
34
|
+
# scrubbing is broken.
|
|
35
|
+
def scrub(item, fields)
|
|
36
|
+
return item if fields.nil? || fields.empty?
|
|
37
|
+
|
|
38
|
+
normalized_fields = fields.map { |f| f.to_s.downcase }
|
|
39
|
+
scrubbed = SCRUBBED_KEYS.each_with_object({}) do |key, out|
|
|
40
|
+
next unless item.key?(key)
|
|
41
|
+
|
|
42
|
+
out[key] = deep_scrub(item[key], normalized_fields)
|
|
43
|
+
end
|
|
44
|
+
item.merge(scrubbed)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
item
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def deep_scrub(value, fields)
|
|
50
|
+
case value
|
|
51
|
+
when Hash
|
|
52
|
+
value.each_with_object({}) do |(key, val), out|
|
|
53
|
+
out[key] = sensitive_key?(key, fields) ? REDACTED : deep_scrub(val, fields)
|
|
54
|
+
end
|
|
55
|
+
when Array
|
|
56
|
+
value.map { |v| deep_scrub(v, fields) }
|
|
57
|
+
else
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sensitive_key?(key, fields)
|
|
63
|
+
normalized_key = key.to_s.downcase
|
|
64
|
+
fields.any? { |field| normalized_key.include?(field) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Request-scoped session-health tracker for AL+ Observe's crash-free
|
|
5
|
+
# sessions metric (issue #12). One request = one session, Sentry
|
|
6
|
+
# "request-mode" style: `Alplus::RackMiddleware` opens it, this class
|
|
7
|
+
# accumulates its outcome as the request runs, and the middleware closes
|
|
8
|
+
# it once `@app.call` returns (or raises).
|
|
9
|
+
#
|
|
10
|
+
# State lives on `Thread.current`, exactly like `Scope` and for the same
|
|
11
|
+
# reason: a thread-pool server (Puma, Passenger) reuses OS threads across
|
|
12
|
+
# requests, so this must be reset per request rather than shared.
|
|
13
|
+
#
|
|
14
|
+
# Three outcomes, in ascending severity, matching
|
|
15
|
+
# ARCHITECTURE.md's decision #2 (Sentry-aligned):
|
|
16
|
+
#
|
|
17
|
+
# * `:healthy` -- the request completed with no captured error.
|
|
18
|
+
# * `:errored` -- the request captured a handled error (any
|
|
19
|
+
# `Alplus.capture_exception`/`capture_message` at level
|
|
20
|
+
# `"error"`/`"fatal"`).
|
|
21
|
+
# * `:crashed` -- the request raised an exception that propagated,
|
|
22
|
+
# unhandled, up through `RackMiddleware`. Only this state counts
|
|
23
|
+
# against crash-free sessions.
|
|
24
|
+
#
|
|
25
|
+
# Severity only ever increases within one request: `mark_errored` is a
|
|
26
|
+
# no-op once `mark_crashed` has run (`RackMiddleware`'s rescue calls
|
|
27
|
+
# `Alplus.capture_exception` for the crash itself, which would otherwise
|
|
28
|
+
# downgrade the outcome back to `:errored`).
|
|
29
|
+
class Session
|
|
30
|
+
THREAD_KEY = :alplus_session
|
|
31
|
+
private_constant :THREAD_KEY
|
|
32
|
+
|
|
33
|
+
SEVERITY = { healthy: 0, errored: 1, crashed: 2 }.freeze
|
|
34
|
+
private_constant :SEVERITY
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# The current thread/fiber's session, or `nil` if none was started
|
|
38
|
+
# (e.g. outside `RackMiddleware`, such as a background job).
|
|
39
|
+
def current
|
|
40
|
+
Thread.current[THREAD_KEY]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Replaces the current thread's session with a fresh one and yields;
|
|
44
|
+
# restores whatever session (if any) was active before, even if the
|
|
45
|
+
# block raises. `RackMiddleware` wraps each request in this so a
|
|
46
|
+
# session from request A never leaks into request B on a reused
|
|
47
|
+
# thread-pool thread.
|
|
48
|
+
def with_clean_session
|
|
49
|
+
previous = Thread.current[THREAD_KEY]
|
|
50
|
+
Thread.current[THREAD_KEY] = new
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
Thread.current[THREAD_KEY] = previous
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :id, :status, :started_at
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@id = Id.generate_session_id
|
|
61
|
+
@status = :healthy
|
|
62
|
+
@started_at = Time.now.utc
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Marks the session `:errored`, unless it is already `:crashed`.
|
|
66
|
+
def mark_errored
|
|
67
|
+
bump(:errored)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Marks the session `:crashed`. Terminal: never downgraded within one request.
|
|
71
|
+
def mark_crashed
|
|
72
|
+
bump(:crashed)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def bump(new_status)
|
|
78
|
+
@status = new_status if SEVERITY.fetch(new_status) > SEVERITY.fetch(@status)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Optional Sidekiq integration. Namespaced under `Alplus::Sidekiq` (not
|
|
5
|
+
# top-level) so this file never collides with the real `::Sidekiq`
|
|
6
|
+
# constant it wraps -- `ErrorHandler` below refers to it explicitly via
|
|
7
|
+
# `::Sidekiq` wherever the ambiguity would otherwise resolve to
|
|
8
|
+
# `Alplus::Sidekiq` instead.
|
|
9
|
+
#
|
|
10
|
+
# This whole file is safe to load unconditionally (it defines classes,
|
|
11
|
+
# nothing more) -- `alplus.rb` only `require_relative`s it when
|
|
12
|
+
# `defined?(::Sidekiq)` is already true, and `install!` itself re-checks
|
|
13
|
+
# that guard, so a host app that never loads the `sidekiq` gem never
|
|
14
|
+
# pays for or activates any of this. The gem's runtime dependency list
|
|
15
|
+
# stays empty either way (`sidekiq` is never `require`d from here).
|
|
16
|
+
module Sidekiq
|
|
17
|
+
# Sidekiq server middleware: captures a job's raised exception with
|
|
18
|
+
# job context (class, queue, scrubbed args), then RE-RAISES so
|
|
19
|
+
# Sidekiq's own retry/dead-set handling still runs unchanged -- this
|
|
20
|
+
# is an observer, not an error handler that swallows failures.
|
|
21
|
+
class ErrorHandler
|
|
22
|
+
def call(worker, job, queue)
|
|
23
|
+
yield
|
|
24
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
25
|
+
capture(worker, job, queue, e)
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def capture(worker, job, queue, exception)
|
|
32
|
+
Alplus.capture_exception(
|
|
33
|
+
exception,
|
|
34
|
+
mechanism: "sidekiq",
|
|
35
|
+
contexts: { job: job_context(worker, job, queue) }
|
|
36
|
+
)
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def job_context(worker, job, queue)
|
|
42
|
+
{
|
|
43
|
+
class: job["class"] || job["wrapped"] || worker.class.name,
|
|
44
|
+
queue: queue || job["queue"],
|
|
45
|
+
jid: job["jid"],
|
|
46
|
+
args: Scrubber.deep_scrub(job["args"], Alplus.configuration.scrub_fields.map { |f| f.to_s.downcase })
|
|
47
|
+
}.compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Idempotent: installs `ErrorHandler` onto Sidekiq's server
|
|
53
|
+
# middleware chain exactly once per process, even if called more
|
|
54
|
+
# than once (e.g. re-evaluated in a reloading dev environment).
|
|
55
|
+
def install!
|
|
56
|
+
return if @installed
|
|
57
|
+
return unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
58
|
+
|
|
59
|
+
::Sidekiq.configure_server do |config|
|
|
60
|
+
next unless config.respond_to?(:server_middleware)
|
|
61
|
+
|
|
62
|
+
config.server_middleware do |chain|
|
|
63
|
+
chain.add Alplus::Sidekiq::ErrorHandler unless chain.exists?(Alplus::Sidekiq::ErrorHandler)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
@installed = true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Test-only: lets a spec re-drive `install!`. Not called by
|
|
70
|
+
# production code.
|
|
71
|
+
def reset!
|
|
72
|
+
@installed = false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|