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
data/lib/alplus/stack.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# Maps a Ruby backtrace to the wire `frames[]` shape
|
|
5
|
+
# (`{file, function, lineno, in_app}`) with in-app vs library detection.
|
|
6
|
+
#
|
|
7
|
+
# Prefers `Exception#backtrace_locations` (structured, available since
|
|
8
|
+
# Ruby 2.0) over parsing `#backtrace` strings; falls back to string
|
|
9
|
+
# parsing only for backtraces that don't expose locations (e.g. a
|
|
10
|
+
# synthetic backtrace assigned by hand).
|
|
11
|
+
module Stack
|
|
12
|
+
# Path fragments that mark a frame as library code regardless of
|
|
13
|
+
# `app_dirs`: installed gems, the Ruby stdlib, and common version
|
|
14
|
+
# manager layouts. Checked before `app_dirs`, so a gem vendored inside
|
|
15
|
+
# the app directory is still treated as library code.
|
|
16
|
+
LIBRARY_MARKERS = [
|
|
17
|
+
"/gems/",
|
|
18
|
+
"/lib/ruby/",
|
|
19
|
+
"/vendor/bundle/",
|
|
20
|
+
"/.rbenv/",
|
|
21
|
+
"/.rvm/",
|
|
22
|
+
"/.asdf/",
|
|
23
|
+
"<internal:"
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
LINE_PATTERN = /\A(.+):(\d+):in [`'"](.+)['"]\z/.freeze
|
|
27
|
+
|
|
28
|
+
# Longest source line kept in `pre_context`/`context_line`/
|
|
29
|
+
# `post_context` (issue: source-context frames) -- a single absurdly
|
|
30
|
+
# long line (minified/generated code) is truncated rather than blowing
|
|
31
|
+
# up the envelope.
|
|
32
|
+
MAX_SOURCE_LINE_CHARS = 500
|
|
33
|
+
|
|
34
|
+
# Per-process cache of a source file's lines. A deep in-app backtrace,
|
|
35
|
+
# or an error storm, otherwise re-reads the same files on the capturing
|
|
36
|
+
# thread on every event. Source does not change within a process, so
|
|
37
|
+
# each file is read at most once. Bounded (FIFO eviction) so it cannot
|
|
38
|
+
# grow without limit.
|
|
39
|
+
MAX_CACHED_SOURCE_FILES = 256
|
|
40
|
+
SOURCE_CACHE = {}
|
|
41
|
+
SOURCE_CACHE_MUTEX = Mutex.new
|
|
42
|
+
private_constant :SOURCE_CACHE, :SOURCE_CACHE_MUTEX
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# `context_lines:` (default `0`, i.e. disabled) is `config.context_lines`
|
|
47
|
+
# at call sites -- see `Envelope.exception_item`. Source context is only
|
|
48
|
+
# ever attached to `in_app` frames: library/gem frames have no value to
|
|
49
|
+
# a host app's developer and reading arbitrary gem source is wasted
|
|
50
|
+
# work.
|
|
51
|
+
def frames_for(exception, app_dirs: [], context_lines: 0)
|
|
52
|
+
locations = exception.respond_to?(:backtrace_locations) ? exception.backtrace_locations : nil
|
|
53
|
+
if locations
|
|
54
|
+
locations.map { |loc| build_frame(loc.path, loc.lineno, loc.label, app_dirs, context_lines) }
|
|
55
|
+
else
|
|
56
|
+
Array(exception.backtrace).filter_map { |line| frame_from_line(line, app_dirs, context_lines) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_frame(path, lineno, label, app_dirs, context_lines = 0)
|
|
61
|
+
in_app = in_app?(path, app_dirs)
|
|
62
|
+
frame = { file: path, lineno: lineno, function: label, in_app: in_app }
|
|
63
|
+
frame.merge!(source_context(path, lineno, context_lines)) if in_app && context_lines.to_i.positive?
|
|
64
|
+
frame.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def frame_from_line(line, app_dirs, context_lines = 0)
|
|
68
|
+
match = LINE_PATTERN.match(line)
|
|
69
|
+
return nil unless match
|
|
70
|
+
|
|
71
|
+
build_frame(match[1], match[2].to_i, match[3], app_dirs, context_lines)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reads `context_lines` lines before/after `lineno` (1-indexed, as
|
|
75
|
+
# backtraces report it) from `path`. Returns `{}` (attaching nothing)
|
|
76
|
+
# for a missing/unreadable file or an out-of-range line number --
|
|
77
|
+
# never raises, matching every other fail-safe boundary in this SDK.
|
|
78
|
+
def source_context(path, lineno, context_lines)
|
|
79
|
+
return {} unless path && File.file?(path) && File.readable?(path)
|
|
80
|
+
|
|
81
|
+
lines = cached_source_lines(path)
|
|
82
|
+
index = lineno - 1
|
|
83
|
+
return {} unless index >= 0 && index < lines.length
|
|
84
|
+
|
|
85
|
+
start_index = [index - context_lines, 0].max
|
|
86
|
+
end_index = [index + context_lines, lines.length - 1].min
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
pre_context: lines[start_index...index].map { |line| cap_source_line(line) },
|
|
90
|
+
context_line: cap_source_line(lines[index]),
|
|
91
|
+
post_context: lines[(index + 1)..end_index].map { |line| cap_source_line(line) }
|
|
92
|
+
}
|
|
93
|
+
rescue StandardError
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns `path`'s lines, reading from disk at most once per process.
|
|
98
|
+
# FIFO-evicts the oldest entry past the cap. Holds the mutex across the
|
|
99
|
+
# read so concurrent captures of the same file do not each read it.
|
|
100
|
+
def cached_source_lines(path)
|
|
101
|
+
SOURCE_CACHE_MUTEX.synchronize do
|
|
102
|
+
return SOURCE_CACHE[path] if SOURCE_CACHE.key?(path)
|
|
103
|
+
|
|
104
|
+
lines = File.readlines(path)
|
|
105
|
+
SOURCE_CACHE[path] = lines
|
|
106
|
+
SOURCE_CACHE.shift if SOURCE_CACHE.size > MAX_CACHED_SOURCE_FILES
|
|
107
|
+
lines
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cap_source_line(line)
|
|
112
|
+
line = line.chomp
|
|
113
|
+
line.length > MAX_SOURCE_LINE_CHARS ? line[0, MAX_SOURCE_LINE_CHARS] : line
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def in_app?(path, app_dirs)
|
|
117
|
+
return false if path.nil?
|
|
118
|
+
return false if LIBRARY_MARKERS.any? { |marker| path.include?(marker) }
|
|
119
|
+
return true if app_dirs.empty?
|
|
120
|
+
|
|
121
|
+
app_dirs.any? { |dir| path.start_with?(dir.to_s) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
# In-memory recorder for host tests. Start with `config.test_mode = true`.
|
|
5
|
+
#
|
|
6
|
+
# Alplus.configure { |c| c.test_mode = true }
|
|
7
|
+
# Alplus.capture_exception(error)
|
|
8
|
+
# Alplus.flush
|
|
9
|
+
# item = Alplus::Testing.events.first
|
|
10
|
+
module Testing
|
|
11
|
+
def self.events
|
|
12
|
+
Array(transport&.envelopes).flat_map { |envelope| envelope[:items] || [] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.sessions
|
|
16
|
+
Array(transport&.session_envelopes).flat_map { |envelope| envelope[:items] || [] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.reset!
|
|
20
|
+
Alplus.reset!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.transport
|
|
24
|
+
Alplus.initialized_client&.transport
|
|
25
|
+
end
|
|
26
|
+
private_class_method :transport
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Alplus
|
|
8
|
+
# Sends one envelope to `POST /e/errors` over `Net::HTTP` with an explicit
|
|
9
|
+
# open/read timeout. Never raises: every transport failure (timeout,
|
|
10
|
+
# connection refused, 4xx/5xx, an unparseable response) is swallowed and
|
|
11
|
+
# reported as `:error`/`:rejected` so the caller (the background worker)
|
|
12
|
+
# can log it without ever propagating into the host app (issue #14 story
|
|
13
|
+
# 8).
|
|
14
|
+
#
|
|
15
|
+
# Retries a transient failure up to `Retry::MAX_ATTEMPTS` times with
|
|
16
|
+
# jittered exponential backoff, honoring a 429's `Retry-After` header
|
|
17
|
+
# (issue #15) — see `Retry` for the shared loop with `Heartbeat`. Runs on
|
|
18
|
+
# the existing background `Worker` thread, never the request thread.
|
|
19
|
+
class Transport
|
|
20
|
+
def initialize(config, sleeper: method(:sleep))
|
|
21
|
+
@config = config
|
|
22
|
+
@sleeper = sleeper
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `kind:` selects the ingest path (issue #12): `:error` (default) posts
|
|
26
|
+
# to `POST /e/errors`, `:session` to `POST /e/sessions`. The error
|
|
27
|
+
# envelope's shape and endpoint are unchanged — this is purely additive
|
|
28
|
+
# routing on the same `Worker` queue/thread.
|
|
29
|
+
def send_envelope(envelope, kind: :error)
|
|
30
|
+
body = JSON.generate(envelope)
|
|
31
|
+
return :oversized if body.bytesize > Envelope::MAX_ENVELOPE_BYTES
|
|
32
|
+
|
|
33
|
+
uri = URI.join(@config.endpoint, path_for(kind))
|
|
34
|
+
result = Retry.perform(sleeper: @sleeper) { post(uri, body) }
|
|
35
|
+
outcome(result)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def path_for(kind)
|
|
41
|
+
kind == :session ? "/e/sessions" : "/e/errors"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def post(uri, body)
|
|
45
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
46
|
+
http.use_ssl = uri.scheme == "https"
|
|
47
|
+
http.open_timeout = @config.open_timeout
|
|
48
|
+
http.read_timeout = @config.read_timeout
|
|
49
|
+
|
|
50
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
51
|
+
request["Content-Type"] = "application/json"
|
|
52
|
+
request["Authorization"] = "Bearer #{@config.key}"
|
|
53
|
+
request.body = body
|
|
54
|
+
|
|
55
|
+
http.request(request)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def outcome(result)
|
|
59
|
+
case result.outcome
|
|
60
|
+
when :sent then :sent
|
|
61
|
+
when :permanent then :rejected
|
|
62
|
+
else
|
|
63
|
+
if result.error
|
|
64
|
+
@config.logger&.warn("[alplus] transport failed: #{result.error.class}: #{result.error.message}")
|
|
65
|
+
:error
|
|
66
|
+
else
|
|
67
|
+
:rejected
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# In-memory transport used when `config.test_mode` is true. Records every
|
|
74
|
+
# envelope it would have sent instead of touching the network, so specs
|
|
75
|
+
# can assert on the exact shape without a stubbed HTTP endpoint —
|
|
76
|
+
# `Alplus.test_transport.envelopes` (issue #14 story 9).
|
|
77
|
+
class TestTransport
|
|
78
|
+
attr_reader :envelopes, :session_envelopes
|
|
79
|
+
|
|
80
|
+
def initialize(*)
|
|
81
|
+
@envelopes = []
|
|
82
|
+
@session_envelopes = []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# `kind:` mirrors `Transport#send_envelope` (issue #12): a `:session`
|
|
86
|
+
# envelope records to `session_envelopes` instead of `envelopes`, so
|
|
87
|
+
# existing specs asserting on `envelopes` (error envelopes only) are
|
|
88
|
+
# unaffected by session traffic.
|
|
89
|
+
def send_envelope(envelope, kind: :error)
|
|
90
|
+
if kind == :session
|
|
91
|
+
@session_envelopes << envelope
|
|
92
|
+
else
|
|
93
|
+
@envelopes << envelope
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
:sent
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def clear
|
|
100
|
+
@envelopes.clear
|
|
101
|
+
@session_envelopes.clear
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alplus
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
SDK_NAME = "alplus-ruby"
|
|
6
|
+
|
|
7
|
+
# Sentinel default for `user:` on `Client#capture_exception`/
|
|
8
|
+
# `#capture_message` — distinguishes "no per-call override given" (fall
|
|
9
|
+
# back to the ambient `Scope` user) from an explicit `user: nil` (clear
|
|
10
|
+
# the ambient user for this one capture), matching the JS SDK's
|
|
11
|
+
# `overrides.user !== undefined` check in `scope.ts`'s `mergeScope`.
|
|
12
|
+
UNSET = Object.new.freeze
|
|
13
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module Alplus
|
|
6
|
+
# Background-thread sender with a bounded queue: `#enqueue` never blocks
|
|
7
|
+
# the caller. When the queue is full, the event is dropped (non-
|
|
8
|
+
# authoritative, matching the JS SDK's own batching drop behavior) rather
|
|
9
|
+
# than applying backpressure to the request thread (issue #14 story 7/8).
|
|
10
|
+
#
|
|
11
|
+
# The worker thread is lazily started on first enqueue and is never
|
|
12
|
+
# joined/killed explicitly: Ruby terminates all non-main threads when the
|
|
13
|
+
# process exits, so there is nothing to supervise for a short-lived
|
|
14
|
+
# script or a `Rack::Handler` process to leak.
|
|
15
|
+
#
|
|
16
|
+
# One `Worker` instance is ONE independent delivery lane: its own
|
|
17
|
+
# `SizedQueue` and its own background thread, fixed to one `kind:`
|
|
18
|
+
# (`:error` or `:session`) for its whole lifetime (issue #12 fix). A
|
|
19
|
+
# PRIOR version routed both kinds through a single shared queue/thread —
|
|
20
|
+
# a stalled or slow `/e/errors` POST (up to ~20s across
|
|
21
|
+
# `Retry::MAX_ATTEMPTS` retries) head-of-line-blocked every queued
|
|
22
|
+
# session behind it, and a full queue during an error storm silently
|
|
23
|
+
# dropped the next session, exactly when crash-free data matters most.
|
|
24
|
+
# `Client` now owns two `Worker`s so error backpressure can never delay
|
|
25
|
+
# or drop session delivery, or vice versa.
|
|
26
|
+
class Worker
|
|
27
|
+
def initialize(config, transport, kind: :error)
|
|
28
|
+
@config = config
|
|
29
|
+
@transport = transport
|
|
30
|
+
@kind = kind
|
|
31
|
+
@queue = SizedQueue.new(config.max_queue_size)
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@thread = nil
|
|
34
|
+
# Outstanding work count: incremented (under `@mutex`) the moment an
|
|
35
|
+
# envelope is successfully pushed, decremented only after
|
|
36
|
+
# `Transport#send_envelope` returns. `SizedQueue#pop` removes an item
|
|
37
|
+
# from the queue *before* the worker thread finishes sending it, so
|
|
38
|
+
# `@queue.empty?` alone goes true while a send is still in flight —
|
|
39
|
+
# a concurrent `#flush`/`#close` reading only queue emptiness would
|
|
40
|
+
# return early (TOCTOU). Counting outstanding work instead of
|
|
41
|
+
# sampling a flag set after pop closes that window: the increment
|
|
42
|
+
# happens atomically with the enqueue that a caller already observed
|
|
43
|
+
# succeeding, not after some later point the reader could race.
|
|
44
|
+
@outstanding = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Enqueues an envelope for background delivery on THIS worker's own
|
|
48
|
+
# lane (its `kind:`, fixed at construction). Returns `true` if queued,
|
|
49
|
+
# `false` if dropped because the queue is full. Never raises.
|
|
50
|
+
def enqueue(envelope)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@queue.push(envelope, true)
|
|
53
|
+
@outstanding += 1
|
|
54
|
+
end
|
|
55
|
+
ensure_thread_started
|
|
56
|
+
true
|
|
57
|
+
rescue ThreadError
|
|
58
|
+
@config.logger&.warn("[alplus] #{@kind} queue full (max #{@config.max_queue_size}); dropping event")
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Blocks up to `timeout` seconds for the queue to drain and any
|
|
63
|
+
# in-flight send to finish. Returns `true` if it drained in time,
|
|
64
|
+
# `false` on timeout. Never raises.
|
|
65
|
+
def flush(timeout: 2)
|
|
66
|
+
deadline = Time.now + timeout
|
|
67
|
+
sleep(0.01) while !idle? && Time.now < deadline
|
|
68
|
+
idle?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def queue_size
|
|
72
|
+
@queue.size
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def idle?
|
|
78
|
+
@mutex.synchronize { @outstanding.zero? }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_thread_started
|
|
82
|
+
return if @thread&.alive?
|
|
83
|
+
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
next if @thread&.alive?
|
|
86
|
+
|
|
87
|
+
@thread = Thread.new { run }
|
|
88
|
+
@thread.abort_on_exception = false
|
|
89
|
+
@thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run
|
|
94
|
+
loop do
|
|
95
|
+
envelope = @queue.pop
|
|
96
|
+
begin
|
|
97
|
+
@transport.send_envelope(envelope, kind: @kind)
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
@config.logger&.warn("[alplus] worker error: #{e.class}: #{e.message}")
|
|
100
|
+
ensure
|
|
101
|
+
@mutex.synchronize { @outstanding -= 1 }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/alplus.rb
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "alplus/version"
|
|
4
|
+
require_relative "alplus/id"
|
|
5
|
+
require_relative "alplus/scrubber"
|
|
6
|
+
require_relative "alplus/configuration"
|
|
7
|
+
require_relative "alplus/stack"
|
|
8
|
+
require_relative "alplus/envelope"
|
|
9
|
+
require_relative "alplus/retry"
|
|
10
|
+
require_relative "alplus/transport"
|
|
11
|
+
require_relative "alplus/worker"
|
|
12
|
+
require_relative "alplus/dedup"
|
|
13
|
+
require_relative "alplus/scope"
|
|
14
|
+
require_relative "alplus/session"
|
|
15
|
+
require_relative "alplus/pending_window"
|
|
16
|
+
require_relative "alplus/client"
|
|
17
|
+
require_relative "alplus/logger_breadcrumbs"
|
|
18
|
+
require_relative "alplus/rack_middleware"
|
|
19
|
+
require_relative "alplus/heartbeat"
|
|
20
|
+
|
|
21
|
+
# Error reporting for `POST /e/errors` on AL+ Observe. Mirrors the wire
|
|
22
|
+
# contract of `@alplus/sdk` (TypeScript) and the Elixir SDK (see
|
|
23
|
+
# docs/ARCHITECTURE.md §8, docs/BUILD.md §5).
|
|
24
|
+
#
|
|
25
|
+
# Alplus.configure do |config|
|
|
26
|
+
# config.key = ENV["ALPLUS_KEY"] # alp_... ingest key, `ingest` scope
|
|
27
|
+
# config.environment = "production"
|
|
28
|
+
# config.release = "v1.2.3"
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# Alplus.capture_exception(exception)
|
|
32
|
+
# Alplus.capture_message("something happened", level: "warning")
|
|
33
|
+
#
|
|
34
|
+
# Never raises into the host app: every public method here swallows its own
|
|
35
|
+
# internal errors and always returns the generated `err_` event id.
|
|
36
|
+
module Alplus
|
|
37
|
+
# Guards the `@client`/`@configuration` singleton memoization below.
|
|
38
|
+
# Module-level (not lazily built) so there is no first-access race on the
|
|
39
|
+
# mutex itself.
|
|
40
|
+
CLIENT_MUTEX = Mutex.new
|
|
41
|
+
private_constant :CLIENT_MUTEX
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def configuration
|
|
45
|
+
@configuration ||= Configuration.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def configure
|
|
49
|
+
yield(configuration) if block_given?
|
|
50
|
+
CLIENT_MUTEX.synchronize { @client = nil }
|
|
51
|
+
configuration
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Memoized under a mutex: two threads racing the very first capture
|
|
55
|
+
# call must not each construct a `Client` (and, with it, a second
|
|
56
|
+
# background `Worker` thread).
|
|
57
|
+
def client
|
|
58
|
+
return @client if @client
|
|
59
|
+
|
|
60
|
+
CLIENT_MUTEX.synchronize { @client ||= Client.new(configuration) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The memoized client if one exists, without constructing it. The
|
|
64
|
+
# log-breadcrumb hook (issue #47) uses this: a boot-time log line must
|
|
65
|
+
# not force client construction before the host finishes configuring.
|
|
66
|
+
def initialized_client
|
|
67
|
+
@client
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def capture_exception(exception, **options)
|
|
71
|
+
client.capture_exception(exception, **options)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
Id.generate_event_id
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def capture_message(message, **options)
|
|
77
|
+
client.capture_message(message, **options)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
Id.generate_event_id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Blocks up to `timeout` seconds for the background queue to drain.
|
|
83
|
+
# Mainly useful in tests and at the end of a short-lived script.
|
|
84
|
+
def flush(timeout: 2)
|
|
85
|
+
client.flush(timeout: timeout)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Pings AL+ Monitor's `GET|POST /h/:token` for cron/job liveness
|
|
91
|
+
# (issue #16): `state:` is `"start"`, `"finish"` (the default), or
|
|
92
|
+
# `"fail"`. Reuses the same retry/backoff as event delivery (`Retry`).
|
|
93
|
+
# Fail-safe: never raises into the caller, always returns `nil`.
|
|
94
|
+
def heartbeat(token, state: "finish")
|
|
95
|
+
Heartbeat.ping(token, state: state, config: configuration)
|
|
96
|
+
nil
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Request-scoped scope ergonomics (issue #17): set once (typically at
|
|
102
|
+
# the top of a request, e.g. in a `before_action`) and applied to every
|
|
103
|
+
# `capture_exception`/`capture_message` call for the rest of the
|
|
104
|
+
# current thread/request. See `Scope`. Every setter is fail-safe.
|
|
105
|
+
def set_user(user)
|
|
106
|
+
Scope.current.set_user(user)
|
|
107
|
+
nil
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def set_tag(key, value)
|
|
113
|
+
Scope.current.set_tag(key, value)
|
|
114
|
+
nil
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def set_context(name, data)
|
|
120
|
+
Scope.current.set_context(name, data)
|
|
121
|
+
nil
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_breadcrumb(message: nil, category: nil, level: nil, data: nil, ts: nil)
|
|
127
|
+
Scope.current.add_breadcrumb(message: message, category: category, level: level, data: data, ts: ts)
|
|
128
|
+
nil
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Closes the current thread's request-scoped `Session` (issue #12), if
|
|
134
|
+
# one is active (see `RackMiddleware`): reports it to
|
|
135
|
+
# `POST /e/sessions` via `Client#report_session`. A no-op if no session
|
|
136
|
+
# is active. Fail-safe: never raises. Not typically called directly —
|
|
137
|
+
# `RackMiddleware` calls this once `@app.call` returns or raises.
|
|
138
|
+
def close_session
|
|
139
|
+
session = Session.current
|
|
140
|
+
return nil unless session
|
|
141
|
+
|
|
142
|
+
client.report_session(session)
|
|
143
|
+
nil
|
|
144
|
+
rescue StandardError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# :nodoc: host tests use `Alplus::Testing`.
|
|
149
|
+
def test_transport
|
|
150
|
+
client.transport
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# :nodoc: host tests use `Alplus::Testing.reset!`.
|
|
154
|
+
def reset!
|
|
155
|
+
CLIENT_MUTEX.synchronize { @client = nil }
|
|
156
|
+
@configuration = nil
|
|
157
|
+
Dedup.reset!
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
require_relative "alplus/testing"
|
|
163
|
+
require_relative "alplus/railtie" if defined?(::Rails::Railtie)
|
|
164
|
+
|
|
165
|
+
# Optional integrations (issue: SDK parity #4/#5): only loaded/activated
|
|
166
|
+
# when the host app already loaded the corresponding library. Neither gem
|
|
167
|
+
# is ever `require`d by this file, and this SDK never declares either as a
|
|
168
|
+
# runtime dependency -- see `lib/alplus/sidekiq.rb`/`lib/alplus/active_job.rb`.
|
|
169
|
+
#
|
|
170
|
+
# This top-level block is the NON-RAILS fallback (or the case where the
|
|
171
|
+
# host requires `sidekiq`/`active_job` before `alplus`). Under Rails,
|
|
172
|
+
# `ActiveJob::Base` autoloads only after boot, so these `defined?` checks
|
|
173
|
+
# are false when the gem loads; the Railtie re-runs the install after boot
|
|
174
|
+
# with correct timing (see `railtie.rb`). `install!` is idempotent, so the
|
|
175
|
+
# two paths never double-install.
|
|
176
|
+
if defined?(::Sidekiq)
|
|
177
|
+
require_relative "alplus/sidekiq"
|
|
178
|
+
Alplus::Sidekiq.install!
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if defined?(::ActiveJob::Base)
|
|
182
|
+
require_relative "alplus/active_job"
|
|
183
|
+
Alplus::ActiveJob.install!
|
|
184
|
+
end
|