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
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
|
data/lib/alplus/dedup.rb
ADDED
|
@@ -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
|