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,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