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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c59904cfc5232b665e00ba30cfb625b7cc6e5677a5c8c135ca323304ab3c2b42
4
+ data.tar.gz: 12a71a80e9afc040162858477d9989c64f68be877d4a2118f38a5b7eab54507a
5
+ SHA512:
6
+ metadata.gz: b7774930fbb1e70090d82792b1b57c7b126d416cf1ec1096b8fda3c8d7b0e474765bb622028ec1066d87af4082474e257e2cdf9e4816f1c1ea178103a7f075fe
7
+ data.tar.gz: f23720f0f9ed1070ceb3f9af63e63898bdbf5a6b1977ecdb32dfe7983cee1fb84decfe285634aa2dee3cedfba13f28166f32fae5eb6a7235c397e15fdf5cf3f3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ALPLUS TECHNOLOGIES S.R.L.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # alplus-ruby
2
+
3
+ Error reporting for [AL+ Observe](https://alplus.dev). Ruby and Rails.
4
+
5
+ Zero runtime dependencies: `Net::HTTP` and the stdlib only.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "alplus-ruby", require: "alplus"
12
+ ```
13
+
14
+ Set `ALPLUS_KEY` (an ingest key with the `ingest` scope).
15
+
16
+ ## Rails
17
+
18
+ Add the gem. The railtie installs `Alplus::RackMiddleware` and captures
19
+ unhandled exceptions. No further wiring is required.
20
+
21
+ Identify the current user in a `before_action`:
22
+
23
+ ```ruby
24
+ Alplus.set_user(id: user.id, email: user.email)
25
+ Alplus.set_tag("org_id", org.id)
26
+ ```
27
+
28
+ ## Capture
29
+
30
+ ```ruby
31
+ begin
32
+ risky_operation!
33
+ rescue => e
34
+ Alplus.capture_exception(e, context: { order_id: order.id })
35
+ end
36
+
37
+ Alplus.capture_message("low disk space", level: "warning")
38
+ ```
39
+
40
+ Both methods return an `err_` event id and never raise.
41
+
42
+ ## Heartbeat
43
+
44
+ ```ruby
45
+ Alplus.heartbeat(token)
46
+ Alplus.heartbeat(token, state: "fail")
47
+ ```
48
+
49
+ ## Plain Rack
50
+
51
+ ```ruby
52
+ use Alplus::RackMiddleware
53
+ ```
54
+
55
+ ## Config
56
+
57
+ The key is read from `ALPLUS_KEY` by default. It is never logged.
58
+
59
+ ```ruby
60
+ Alplus.configure do |config|
61
+ config.environment = "production"
62
+ config.release = ENV["GIT_SHA"]
63
+ config.before_send = ->(item) { item }
64
+ end
65
+ ```
66
+
67
+ `before_send` receives the built item. Return `nil` to drop it. A raise
68
+ sends the original item.
69
+
70
+ ## Tests
71
+
72
+ ```ruby
73
+ Alplus.configure { |c| c.test_mode = true }
74
+
75
+ Alplus.capture_exception(error)
76
+ Alplus.flush
77
+ item = Alplus::Testing.events.first
78
+ ```
79
+
80
+ Nothing hits the network. Set `config.enabled = false` to disable capture.
81
+
82
+ ## Development
83
+
84
+ ```
85
+ cd sdks/ruby
86
+ bundle install
87
+ export ALPLUS_CONTRACT_DIR=../../sdks/contract
88
+ bundle exec rspec
89
+ ```
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Alplus
6
+ # Optional ActiveJob integration. Namespaced under `Alplus::ActiveJob`
7
+ # (not top-level) so it never collides with `::ActiveJob`. Safe to load
8
+ # unconditionally -- `alplus.rb` only `require_relative`s this file when
9
+ # `defined?(::ActiveJob::Base)` is already true, and `install!` re-checks
10
+ # that guard, so a host app that never loads ActiveJob never activates
11
+ # any of this. This gem's runtime dependency list stays empty either
12
+ # way: `active_support/concern` is part of ActiveJob's own dependency
13
+ # tree, never `require`d unless ActiveJob already pulled it in.
14
+ module ActiveJob
15
+ # Mixed into `::ActiveJob::Base` by `install!`. Wraps every job's
16
+ # `perform` in an `around_perform` hook: captures the exception with
17
+ # job context (class, queue, scrubbed arguments), then RE-RAISES so
18
+ # ActiveJob's own retry/discard handling (`retry_on`/`discard_on`)
19
+ # still runs unchanged -- this is an observer, not an error handler
20
+ # that swallows failures.
21
+ module ErrorReporting
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ around_perform :alplus_capture_around_perform
26
+ end
27
+
28
+ private
29
+
30
+ def alplus_capture_around_perform
31
+ yield
32
+ rescue Exception => e # rubocop:disable Lint/RescueException
33
+ alplus_capture_job_exception(e)
34
+ raise
35
+ end
36
+
37
+ def alplus_capture_job_exception(exception)
38
+ Alplus.capture_exception(
39
+ exception,
40
+ mechanism: "active_job",
41
+ contexts: { job: alplus_job_context }
42
+ )
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def alplus_job_context
48
+ scrub_fields = Alplus.configuration.scrub_fields.map { |f| f.to_s.downcase }
49
+ {
50
+ class: self.class.name,
51
+ queue: (respond_to?(:queue_name) ? queue_name : nil),
52
+ job_id: (respond_to?(:job_id) ? job_id : nil),
53
+ arguments: Alplus::Scrubber.deep_scrub(arguments, scrub_fields)
54
+ }.compact
55
+ end
56
+ end
57
+
58
+ class << self
59
+ # Idempotent: mixing `ErrorReporting` into `::ActiveJob::Base` more
60
+ # than once is a no-op (Ruby's own `Module#include` is already
61
+ # idempotent for the same module, but `@installed` also skips the
62
+ # guard checks/log-free re-run cheaply).
63
+ def install!
64
+ return if @installed
65
+ return unless defined?(::ActiveJob::Base)
66
+
67
+ ::ActiveJob::Base.include(ErrorReporting)
68
+ @installed = true
69
+ end
70
+
71
+ # Test-only: lets a spec re-drive `install!`. Not called by
72
+ # production code.
73
+ def reset!
74
+ @installed = false
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alplus
4
+ # Orchestrates one configuration's capture pipeline: builds the wire item,
5
+ # wraps it in an envelope, and hands it to the background worker (or, in
6
+ # test mode, sends it synchronously through the in-memory transport).
7
+ #
8
+ # Every public method is fail-safe: a bug anywhere in envelope building or
9
+ # dispatch is caught and logged, never raised into the host app (issue
10
+ # #14 story 8). The event id is always generated and returned first, so a
11
+ # caller can show "reference id err_..." even if the event was dropped.
12
+ class Client
13
+ # `sleeper:` is forwarded to the default `Transport` (never used when
14
+ # `config.transport`/`config.test_mode` supply a different one) purely
15
+ # as a test seam: with the real `Kernel#sleep`, a spec exercising a
16
+ # retried send would otherwise wait on real backoff wall-clock time.
17
+ # Defaults to `config.sleeper` so `Alplus.configure { |c| c.sleeper = ... }`
18
+ # reaches the module-level singleton client too, not just a directly
19
+ # constructed `Client.new`.
20
+ def initialize(config, sleeper: config.sleeper)
21
+ @config = config
22
+ @transport = config.transport || (config.test_mode ? TestTransport.new : Transport.new(config, sleeper: sleeper))
23
+ @worker = Worker.new(config, @transport, kind: :error)
24
+ # A SEPARATE worker (own queue, own thread) from `@worker` (issue
25
+ # #12 fix): see `Worker`'s class doc for why sharing one lane with
26
+ # error delivery was a defect (head-of-line blocking + silent drops
27
+ # under an error storm, exactly when crash-free data matters).
28
+ @session_worker = Worker.new(config, @transport, kind: :session)
29
+
30
+ @pending_window =
31
+ PendingWindow.new(config.resolved_post_error_log_window_ms) { |item| deliver_item(item) }
32
+ end
33
+
34
+ attr_reader :transport
35
+
36
+ # The enabled/sampled gate runs BEFORE dedup registration (issue #15
37
+ # defect): a sampled-out or disabled capture must not occupy the
38
+ # dedup slot, or it would suppress the NEXT (real) in-window capture
39
+ # of the same error.
40
+ #
41
+ # Dedup (issue #15) then runs BEFORE the scope merge / envelope build:
42
+ # the same exception object captured twice within the dedup window
43
+ # (e.g. by `RackMiddleware` auto-capture and a manual rescue further up
44
+ # the stack) returns the first call's id and is never re-queued — see
45
+ # `Dedup`.
46
+ #
47
+ # `contexts:`/`fingerprint:` and the ambient `Scope` (`set_user`,
48
+ # `set_tag`, `set_context`, `add_breadcrumb`) are issue #17: an explicit
49
+ # per-call `user:`/`tags:`/`contexts:`/`breadcrumbs:` here wins over
50
+ # whatever the ambient scope carries, field-by-field — see
51
+ # `ScopeMerge.merge`. `user:` defaults to the `Alplus::UNSET` sentinel,
52
+ # not `nil`, so a caller CAN pass `user: nil` to explicitly clear the
53
+ # ambient user for one capture.
54
+ def capture_exception(exception, level: "error", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: Alplus::UNSET, mechanism: "generic", fingerprint: nil)
55
+ mark_session_outcome(level)
56
+ fresh_id = Id.generate_event_id
57
+ return fresh_id unless enabled?
58
+ return fresh_id if excluded?(exception)
59
+
60
+ resolved = Dedup.resolve(exception, fresh_id)
61
+ return resolved[:id] if resolved[:duplicate]
62
+
63
+ id = resolved[:id]
64
+ dispatch(id) { build_item(:exception_item, exception: exception, id: id, level: level, context: context, contexts: contexts, tags: tags, breadcrumbs: breadcrumbs, user: user, mechanism: mechanism, fingerprint: fingerprint) }
65
+ id
66
+ end
67
+
68
+ def capture_message(message, level: "info", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: Alplus::UNSET, mechanism: "generic", fingerprint: nil)
69
+ mark_session_outcome(level)
70
+ id = Id.generate_event_id
71
+ return id unless enabled?
72
+
73
+ dispatch(id) { build_item(:message_item, message: message, id: id, level: level, context: context, contexts: contexts, tags: tags, breadcrumbs: breadcrumbs, user: user, mechanism: mechanism, fingerprint: fingerprint) }
74
+ id
75
+ end
76
+
77
+ # Flushes BOTH delivery lanes (issue #12: error and session are
78
+ # independent workers). A slow/stuck error lane still bounds this call
79
+ # by `timeout` for the session lane's own drain -- each `Worker#flush`
80
+ # call gets the full `timeout` budget rather than splitting it, since a
81
+ # caller flushing wants both drained, not a race between them.
82
+ def flush(timeout: 2)
83
+ # Seal the post-error log window first: flush means "send now with
84
+ # whatever after-lines were collected so far", never "wait".
85
+ @pending_window.seal_all!
86
+ error_flushed = @worker.flush(timeout: timeout)
87
+ session_flushed = @session_worker.flush(timeout: timeout)
88
+ error_flushed && session_flushed
89
+ rescue StandardError
90
+ false
91
+ end
92
+
93
+ # Offers a log-line breadcrumb to every exception item currently inside
94
+ # its post-error log window on this thread (issue #47). Never raises.
95
+ def notify_log_breadcrumb(crumb)
96
+ @pending_window.notify_log_breadcrumb(crumb)
97
+ end
98
+
99
+ # Reports a closed `Session` (issue #12) to `POST /e/sessions`, on its
100
+ # OWN background `Worker` (own queue, own thread) so a slow/failing
101
+ # `/e/errors` delivery can never delay or drop a queued session, or
102
+ # vice versa -- see `Worker`'s class doc. Unlike `capture_exception`/
103
+ # `capture_message`, never sampled or deduped — an accurate crash-free
104
+ # percentage needs every session counted, not a sample of them. Only
105
+ # gated on `config.valid?` (configured + enabled). Fail-safe: never
106
+ # raises.
107
+ def report_session(session)
108
+ return false unless @config.valid?
109
+
110
+ envelope = Envelope.wrap(config: @config, item: Envelope.session_item(session: session, config: @config))
111
+
112
+ if @config.test_mode
113
+ @transport.send_envelope(envelope, kind: :session)
114
+ else
115
+ @session_worker.enqueue(envelope)
116
+ end
117
+ rescue StandardError => e
118
+ @config.logger&.warn("[alplus] session report failed internally; dropped: #{e.class}: #{e.message}")
119
+ false
120
+ end
121
+
122
+ private
123
+
124
+ # Any `"error"`/`"fatal"`-level capture during the current request
125
+ # marks its `Session` (at least) `:errored` (issue #12) — a no-op if
126
+ # the session is already `:crashed`, or if no session is active (e.g.
127
+ # a background job, not a request under `RackMiddleware`). Runs
128
+ # BEFORE the `enabled?`/sampling gate below: whether or not this
129
+ # particular capture gets sent to `/e/errors`, the request genuinely
130
+ # did produce a handled error.
131
+ def mark_session_outcome(level)
132
+ Session.current&.mark_errored if %w[error fatal].include?(level.to_s)
133
+ end
134
+
135
+ # Single evaluation point for "would this capture actually be sent":
136
+ # `config.sampled?` draws a random number, so it must be called
137
+ # exactly once per capture — calling it again later (e.g. inside
138
+ # `dispatch`) could draw a different result than what already gated
139
+ # dedup registration.
140
+ def enabled?
141
+ @config.valid? && @config.sampled?
142
+ rescue StandardError
143
+ false
144
+ end
145
+
146
+ # Merges the ambient `Scope` with this call's explicit overrides, then
147
+ # delegates to `Envelope.exception_item`/`Envelope.message_item`.
148
+ def build_item(builder, id:, level:, context:, contexts:, tags:, breadcrumbs:, user:, mechanism:, fingerprint:, **item_args)
149
+ merged = ScopeMerge.merge(ambient: Scope.current.snapshot, user: user, tags: tags, contexts: contexts, breadcrumbs: breadcrumbs)
150
+ Envelope.public_send(
151
+ builder,
152
+ id: id,
153
+ config: @config,
154
+ level: level,
155
+ context: context,
156
+ contexts: merged[:contexts].empty? ? nil : merged[:contexts],
157
+ tags: merged[:tags].empty? ? nil : merged[:tags],
158
+ breadcrumbs: merged[:breadcrumbs].empty? ? nil : merged[:breadcrumbs],
159
+ user: merged[:user],
160
+ mechanism: mechanism,
161
+ fingerprint: fingerprint,
162
+ **item_args
163
+ )
164
+ end
165
+
166
+ # `id` is unused directly but kept as a named parameter for readability
167
+ # at call sites and future correlation logging. The enabled/sampled
168
+ # gate already ran in `capture_exception`/`capture_message` before
169
+ # dedup registration, so it is not repeated here.
170
+ def dispatch(_id)
171
+ item = yield
172
+ item = Scrubber.scrub(item, @config.scrub_fields)
173
+ item = apply_before_send(item)
174
+ return unless item
175
+
176
+ if item[:type] == "exception" && @pending_window.enabled?
177
+ # Post-error log window (issue #47): the item lingers so log lines
178
+ # written just after the error join it before delivery.
179
+ @pending_window.hold(item)
180
+ else
181
+ deliver_item(item)
182
+ end
183
+ rescue StandardError => e
184
+ @config.logger&.warn("[alplus] capture failed internally; event dropped: #{e.class}: #{e.message}")
185
+ end
186
+
187
+ def deliver_item(item)
188
+ envelope = Envelope.wrap(config: @config, item: item)
189
+
190
+ if @config.test_mode
191
+ @transport.send_envelope(envelope)
192
+ else
193
+ @worker.enqueue(envelope)
194
+ end
195
+ rescue StandardError => e
196
+ @config.logger&.warn("[alplus] delivery failed internally; event dropped: #{e.class}: #{e.message}")
197
+ end
198
+
199
+ # Exception class name, or the name of any ancestor, matches
200
+ # `config.excluded_exceptions`. Never raises: an internal error (e.g. a
201
+ # weird `class.name` override) is treated as "not excluded" so a
202
+ # scrubbing bug in this check can never silently swallow a real error.
203
+ def excluded?(exception)
204
+ names = @config.excluded_exceptions
205
+ return false if names.nil? || names.empty?
206
+
207
+ exception.class.ancestors.any? { |ancestor| names.include?(ancestor.name) }
208
+ rescue StandardError
209
+ false
210
+ end
211
+
212
+ # `config.before_send` runs AFTER the built-in scrubber (`Scrubber`),
213
+ # so a custom callback still sees redacted secrets, not raw ones. A
214
+ # raising callback must never break capture: on error, the ORIGINAL
215
+ # (already-scrubbed) item is sent instead of the callback's output.
216
+ def apply_before_send(item)
217
+ callback = @config.before_send
218
+ return item unless callback
219
+
220
+ begin
221
+ callback.call(item)
222
+ rescue StandardError => e
223
+ @config.logger&.warn("[alplus] before_send raised; sending original event: #{e.class}: #{e.message}")
224
+ item
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alplus
4
+ # Holds ingest key, endpoint, environment/release tags, and safety knobs.
5
+ # The key is read from `ALPLUS_KEY` by default and is never logged —
6
+ # `#inspect`/`#to_s` omit it deliberately (issue #14 story 11).
7
+ class Configuration
8
+ DEFAULT_ENDPOINT = "https://ingest.alplus.dev"
9
+
10
+ attr_accessor :key, :endpoint, :environment, :release, :sample_rate,
11
+ :enabled, :test_mode, :app_dirs, :max_queue_size,
12
+ :open_timeout, :read_timeout, :logger, :transport, :sleeper,
13
+ :before_send, :scrub_fields, :excluded_exceptions, :context_lines,
14
+ :breadcrumbs_enabled, :logger_breadcrumbs_enabled,
15
+ :post_error_log_window_ms
16
+
17
+ # The window `Client` actually uses: the explicit setting when given,
18
+ # otherwise 0 in test mode (synchronous delivery stays synchronous for
19
+ # every spec that does not opt in) and 2000 ms in a real process.
20
+ def resolved_post_error_log_window_ms
21
+ return post_error_log_window_ms.to_i unless post_error_log_window_ms.nil?
22
+
23
+ test_mode ? 0 : 2_000
24
+ end
25
+
26
+ def initialize
27
+ @key = ENV["ALPLUS_KEY"]
28
+ @endpoint = ENV["ALPLUS_ENDPOINT"] || DEFAULT_ENDPOINT
29
+ @environment = ENV["ALPLUS_ENVIRONMENT"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "production"
30
+ @release = ENV["ALPLUS_RELEASE"]
31
+ @sample_rate = 1.0
32
+ @enabled = true
33
+ @test_mode = false
34
+ @app_dirs = []
35
+ @max_queue_size = 100
36
+ @open_timeout = 2
37
+ @read_timeout = 5
38
+ @logger = nil
39
+ @transport = nil
40
+ # Issue (SDK parity #1): called with the wire item hash just before
41
+ # enqueue. Return a (possibly modified) hash to send it, or `nil` to
42
+ # drop the event entirely. A raising callback never breaks capture --
43
+ # `Client` rescues it and sends the original item. `nil` by default
44
+ # (no-op).
45
+ @before_send = nil
46
+ # Deep-walked (case-insensitive substring match) across
47
+ # `context`/`contexts`/`tags`/`user` before `before_send` runs; a
48
+ # matching value is replaced with `"[FILTERED]"`. See `Scrubber`.
49
+ @scrub_fields = Scrubber::DEFAULT_SCRUB_FIELDS.dup
50
+ # Exception class names (or an ancestor's) to never send at all --
51
+ # e.g. `"ActionController::RoutingError"`. Empty by default: a
52
+ # non-Rails host app gets no surprise silent drops.
53
+ @excluded_exceptions = []
54
+ # Lines of source before/after each in_app frame's line to attach
55
+ # (`Stack`). `0` disables source-context capture entirely.
56
+ @context_lines = 3
57
+ # Auto-breadcrumbs from `ActiveSupport::Notifications` (Rails only,
58
+ # see `Railtie`). Ignored outside Rails.
59
+ @breadcrumbs_enabled = true
60
+ # Log-line breadcrumbs from an attached logger (issue #47,
61
+ # `LoggerBreadcrumbs`; the Railtie attaches `Rails.logger`).
62
+ @logger_breadcrumbs_enabled = true
63
+ # Post-error log window in ms (issue #47): an exception item lingers
64
+ # this long so log lines written just after the error (Rails' own
65
+ # exception logging included) join its breadcrumb timeline, marked
66
+ # `after_error`. `0` disables. `nil` (the default) resolves to 0 in
67
+ # test mode -- specs that exercise the window opt in explicitly --
68
+ # and 2000 otherwise. `flush`/`close` seal pending items immediately.
69
+ @post_error_log_window_ms = nil
70
+ # Injection point for the default `Transport`'s retry backoff sleep
71
+ # (issue #15/#16 follow-up): overridable per-`Configuration` so a
72
+ # spec exercising `Alplus.capture_exception`/`.heartbeat` against a
73
+ # retried response never waits on real wall-clock backoff.
74
+ @sleeper = method(:sleep)
75
+ end
76
+
77
+ def valid?
78
+ !enabled.nil? && enabled && !key.to_s.strip.empty?
79
+ end
80
+
81
+ def sampled?
82
+ sample_rate >= 1.0 || rand < sample_rate
83
+ end
84
+
85
+ # Never expose the key. A future maintainer adding a field here must not
86
+ # add it to this list without checking it isn't a secret.
87
+ def inspect
88
+ "#<Alplus::Configuration endpoint=#{endpoint.inspect} environment=#{environment.inspect} " \
89
+ "release=#{release.inspect} enabled=#{enabled.inspect} test_mode=#{test_mode.inspect}>"
90
+ end
91
+ alias to_s inspect
92
+ end
93
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alplus
4
+ # Exception dedup (issue #15), mirroring
5
+ # `packages/sdk/src/core/observe/dedup.ts`'s `resolveDedupId`: the same
6
+ # error captured twice within a short window (auto-capture AND a manual
7
+ # `capture_exception` for the same raised exception, e.g. the Rack
8
+ # middleware re-raising into a Rails handler that also reports it)
9
+ # produces ONE event, not two.
10
+ #
11
+ # The JS SDK keys identity-bearing errors in a `WeakMap` so a dedup entry
12
+ # never outlives (or pins alive) the error object. Ruby's
13
+ # `ObjectSpace::WeakMap` holds its VALUES weakly too (not just keys) with
14
+ # no other strong referent to a plain dedup-entry object, so a value
15
+ # stashed there is eligible for GC before the next lookup -- unusable for
16
+ # this. Instead, the dedup entry is stashed directly on the error object
17
+ # itself via a hidden instance variable: it lives and dies with the
18
+ # exact same object, which is a stronger and simpler guarantee than a
19
+ # WeakMap gives (zero separate table to leak or prune for this path).
20
+ #
21
+ # A raised String/Symbol/Number/boolean/nil can't hold an instance
22
+ # variable (and has no reference identity worth keying on regardless --
23
+ # two unrelated `"boom"` literals are different objects but the same
24
+ # *error*), so those use a small bounded value-keyed Hash instead, same
25
+ # split as the JS SDK's `isWeakKeyable`.
26
+ module Dedup
27
+ WINDOW_SECONDS = 2.0
28
+ # Bounds the primitive-keyed fallback so a flood of distinct thrown
29
+ # strings can't grow it unboundedly.
30
+ VALUE_CACHE_MAX = 50
31
+ VALUE_KEYABLE_CLASSES = [String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass].freeze
32
+
33
+ IVAR = :@__alplus_dedup_entry__
34
+ private_constant :IVAR
35
+
36
+ Entry = Struct.new(:id, :expires_at)
37
+
38
+ @value_map = {}
39
+ @mutex = Mutex.new
40
+
41
+ class << self
42
+ # Returns `{id:, duplicate:}` — the fresh id for a new error, or the
43
+ # PREVIOUS capture's id (and `duplicate: true`) if `error` was
44
+ # already captured within the window. Never raises: on any internal
45
+ # failure (e.g. a frozen error object rejecting the ivar write),
46
+ # treats it as a fresh, non-duplicate capture rather than risk
47
+ # silently dropping a real error.
48
+ def resolve(error, fresh_id)
49
+ now = monotonic_now
50
+ @mutex.synchronize do
51
+ if value_keyable?(error)
52
+ resolve_value_keyed(error, fresh_id, now)
53
+ else
54
+ resolve_identity_keyed(error, fresh_id, now)
55
+ end
56
+ end
57
+ rescue StandardError
58
+ { id: fresh_id, duplicate: false }
59
+ end
60
+
61
+ # Test-only: clears the value-keyed table between examples. The
62
+ # identity path needs no reset — a fresh `Exception.new` each example
63
+ # carries no leftover ivar.
64
+ def reset!
65
+ @mutex.synchronize { @value_map.clear }
66
+ end
67
+
68
+ private
69
+
70
+ def resolve_identity_keyed(error, fresh_id, now)
71
+ existing = error.instance_variable_defined?(IVAR) ? error.instance_variable_get(IVAR) : nil
72
+ if existing && existing.expires_at > now
73
+ { id: existing.id, duplicate: true }
74
+ else
75
+ error.instance_variable_set(IVAR, Entry.new(fresh_id, now + WINDOW_SECONDS))
76
+ { id: fresh_id, duplicate: false }
77
+ end
78
+ end
79
+
80
+ def resolve_value_keyed(error, fresh_id, now)
81
+ prune_expired(now)
82
+ key = value_key(error)
83
+ existing = @value_map[key]
84
+ if existing && existing.expires_at > now
85
+ { id: existing.id, duplicate: true }
86
+ else
87
+ @value_map.delete(@value_map.keys.first) if @value_map.size >= VALUE_CACHE_MAX
88
+ @value_map[key] = Entry.new(fresh_id, now + WINDOW_SECONDS)
89
+ { id: fresh_id, duplicate: false }
90
+ end
91
+ end
92
+
93
+ def prune_expired(now)
94
+ @value_map.delete_if { |_key, entry| entry.expires_at <= now }
95
+ end
96
+
97
+ def value_keyable?(error)
98
+ VALUE_KEYABLE_CLASSES.any? { |klass| error.is_a?(klass) }
99
+ end
100
+
101
+ def value_key(error)
102
+ "#{error.class}:#{error.inspect}"
103
+ end
104
+
105
+ def monotonic_now
106
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
+ end
108
+ end
109
+ end
110
+ end