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