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,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Alplus
7
+ # Builds the `POST /e/errors` wire envelope. Mirrors
8
+ # `packages/sdk/src/core/observe/{client,envelope}.ts` (the TypeScript
9
+ # SDK) and the Elixir SDK (#13): `{header: {key, sdk, sent_at}, items: [...]}`
10
+ # with one item per request. Kept additive-only per docs/BUILD.md §5 —
11
+ # every field here already exists in the accepted server shape.
12
+ #
13
+ # Per-field caps mirror the JS SDK's own write-boundary caps
14
+ # (`envelope.ts`'s `MAX_*` constants) so an oversized field is trimmed
15
+ # HERE, per field, and the event still sends — the same trade-off the JS
16
+ # SDK makes. `Transport::MAX_ENVELOPE_BYTES`'s whole-envelope check stays
17
+ # a last-resort safety net for the case these per-field caps still don't
18
+ # fit (should be unreachable in practice, same as the JS SDK's own
19
+ # comment on its equivalent guard).
20
+ module Envelope
21
+ MAX_ENVELOPE_BYTES = 1_048_576
22
+ MAX_MESSAGE_CHARS = 4_096
23
+ MAX_EXCEPTION_VALUE_CHARS = 4_096
24
+ MAX_STACK_TRACE_CHARS = 16_384
25
+ MAX_CONTEXT_CHARS = 8_192
26
+ MAX_TAGS_CHARS = 4_096
27
+ SERVER_MAX_BREADCRUMBS = 100
28
+ MAX_BREADCRUMB_MESSAGE_CHARS = 2_048
29
+ MAX_BREADCRUMB_CATEGORY_CHARS = 128
30
+ # The server's `errorItemSchema.user` is `.strict()` with only `id`/
31
+ # `email` (packages/schemas/src/observe/error-envelope.ts) — an
32
+ # unrecognized key rejects the whole item, so `cap_user` below picks
33
+ # only these two rather than forwarding an arbitrary hash. The JS SDK
34
+ # passes `user` through uncapped (no `MAX_USER_*` there); this cap is
35
+ # an SDK-side addition for the same defensive-write-boundary discipline
36
+ # every other free-text field already gets here.
37
+ MAX_USER_FIELD_CHARS = 256
38
+ # Mirrors the server's `errorItemSchema.fingerprint`
39
+ # (`z.array(z.string().max(256)).min(1).max(16)`, issue #17).
40
+ MAX_FINGERPRINT_ENTRIES = 16
41
+ MAX_FINGERPRINT_CHARS = 256
42
+ # The server accepts an exception `cause` chain up to depth 5 counting
43
+ # the top-level exception (`Alplus.Observe.ErrorEnvelope`); walk at most
44
+ # 4 causes so a cyclic or absurd chain can never build an over-deep item.
45
+ MAX_CAUSE_DEPTH = 4
46
+
47
+ module_function
48
+
49
+ def wrap(config:, item:)
50
+ {
51
+ header: {
52
+ key: config.key,
53
+ sdk: { name: Alplus::SDK_NAME, version: Alplus::VERSION, platform: "ruby" },
54
+ sent_at: Time.now.utc.iso8601
55
+ },
56
+ items: [item]
57
+ }
58
+ end
59
+
60
+ # Builds a `POST /e/sessions` wire item (issue #12) from an
61
+ # `Alplus::Session`. Carries no PII: `session.id` is opaque and used
62
+ # server-side only for in-window ingest dedup, then discarded (never
63
+ # stored raw) — matching `Alplus.Observe.SessionEnvelope`, the server
64
+ # parser.
65
+ def session_item(session:, config:)
66
+ {
67
+ id: session.id,
68
+ status: session.status.to_s,
69
+ started_at: session.started_at.iso8601,
70
+ duration_ms: ((Time.now.utc - session.started_at) * 1000).round,
71
+ release: config.release,
72
+ environment: config.environment
73
+ }.compact
74
+ end
75
+
76
+ # `frames:` overrides the normal `Stack.frames_for(exception, ...)`
77
+ # capture with an already-built wire frame array, for callers that
78
+ # already have wire-shaped frames (mirrors the Elixir SDK's
79
+ # `Envelope.build_frame(%{} = wire_frame, _)` passthrough). Real capture
80
+ # callers never pass this; it exists so the golden-envelope contract
81
+ # spec (issue #18) can call this REAL function with the golden's
82
+ # literal, cross-language-reproducible frames instead of a real
83
+ # backtrace tied to one language's stack-trace format.
84
+ def exception_item(id:, exception:, config:, level: "error", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: nil, mechanism: "generic", fingerprint: nil, frames: nil)
85
+ frames ||= Stack.frames_for(exception, app_dirs: config.app_dirs, context_lines: config.context_lines.to_i)
86
+ exc = { type: exception.class.name, value: cap_text(exception.message.to_s, MAX_EXCEPTION_VALUE_CHARS) }
87
+ capped_frames = cap_frames(frames, MAX_STACK_TRACE_CHARS)
88
+ exc[:stacktrace] = { frames: capped_frames } unless capped_frames.empty?
89
+ cause = build_cause(exception.cause, config, MAX_CAUSE_DEPTH)
90
+ exc[:cause] = cause if cause
91
+
92
+ base_item(id: id, type: "exception", level: level, config: config, mechanism: mechanism, context: context, contexts: contexts, tags: tags, breadcrumbs: breadcrumbs, user: user, fingerprint: fingerprint)
93
+ .merge(exception: exc)
94
+ end
95
+
96
+ # Walks `Exception#cause` into the wire `cause` chain (Honeybadger/
97
+ # Sentry-style "caused by"). Each cause carries its own type, message,
98
+ # and backtrace. Depth-bounded; a `cause` loop (possible via handwritten
99
+ # `cause` overrides) terminates at the bound instead of hanging.
100
+ def build_cause(exception, config, depth)
101
+ return nil if exception.nil? || depth <= 0
102
+
103
+ built = { type: exception.class.name, value: cap_text(exception.message.to_s, MAX_EXCEPTION_VALUE_CHARS) }
104
+ frames = Stack.frames_for(exception, app_dirs: config.app_dirs, context_lines: config.context_lines.to_i)
105
+ capped_frames = cap_frames(frames, MAX_STACK_TRACE_CHARS)
106
+ built[:stacktrace] = { frames: capped_frames } unless capped_frames.empty?
107
+ nested = build_cause(exception.cause, config, depth - 1)
108
+ built[:cause] = nested if nested
109
+ built
110
+ end
111
+
112
+ def message_item(id:, message:, config:, level: "info", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: nil, mechanism: "generic", fingerprint: nil)
113
+ base_item(id: id, type: "message", level: level, config: config, mechanism: mechanism, context: context, contexts: contexts, tags: tags, breadcrumbs: breadcrumbs, user: user, fingerprint: fingerprint)
114
+ .merge(message: cap_text(message.to_s, MAX_MESSAGE_CHARS))
115
+ end
116
+
117
+ # `context:` is the free-text convenience that folds into
118
+ # `contexts.extra` (and, per capture, always REPLACES whatever ambient
119
+ # `extra` context the caller's scope carried — it does not deep-merge
120
+ # with it); `contexts:` is the arbitrary named-map form. Both fold into
121
+ # one `contexts` wire key, matching the JS SDK and fixing this SDK's
122
+ # previous `context`-only shape (issue #17).
123
+ def base_item(id:, type:, level:, config:, mechanism:, context:, contexts:, tags:, breadcrumbs:, user:, fingerprint:)
124
+ item = {
125
+ id: id,
126
+ type: type,
127
+ timestamp: Time.now.utc.iso8601,
128
+ level: level.to_s,
129
+ release: config.release,
130
+ environment: config.environment,
131
+ mechanism: mechanism
132
+ }
133
+ named_contexts = contexts ? contexts.dup : {}
134
+ named_contexts[:extra] = context if context && !context.empty?
135
+ item[:contexts] = cap_context(named_contexts, MAX_CONTEXT_CHARS) unless named_contexts.empty?
136
+ capped_tags = cap_tags(tags, MAX_TAGS_CHARS)
137
+ item[:tags] = capped_tags if capped_tags
138
+ capped_crumbs = cap_breadcrumbs(breadcrumbs, SERVER_MAX_BREADCRUMBS)
139
+ item[:breadcrumbs] = capped_crumbs if capped_crumbs && !capped_crumbs.empty?
140
+ capped_user = cap_user(user)
141
+ item[:user] = capped_user if capped_user
142
+ capped_fingerprint = cap_fingerprint(fingerprint)
143
+ item[:fingerprint] = capped_fingerprint if capped_fingerprint
144
+ item.compact
145
+ end
146
+
147
+ # Truncates a string to at most `max_length` characters. Passes
148
+ # non-strings through `#to_s` first; callers already do this, kept
149
+ # defensive here since a truncated field is safer than a raise.
150
+ def cap_text(value, max_length)
151
+ value = value.to_s
152
+ return value if value.length <= max_length
153
+
154
+ value[0, max_length]
155
+ end
156
+
157
+ # Caps a JSON-ish hash by its serialized size. A value whose
158
+ # serialization exceeds `max_chars` is REPLACED by a small truncation
159
+ # marker rather than cut mid-string, mirroring the JS SDK's
160
+ # `capContext` — a partial JSON string is unparseable, which is worse
161
+ # than a shorter one.
162
+ def cap_context(value, max_chars)
163
+ serialized = JSON.generate(value)
164
+ return value if serialized.bytesize <= max_chars
165
+
166
+ { _truncated: true, _original_chars: serialized.bytesize }
167
+ end
168
+
169
+ # Drops the tags object entirely (with a debug-log warning) rather than
170
+ # send a truncated `Hash` that would no longer round-trip as one — same
171
+ # trade-off the JS SDK's `capTags` makes. Returns `nil` for a `nil`/
172
+ # empty input so the caller can omit the key.
173
+ def cap_tags(tags, max_chars)
174
+ return nil if tags.nil? || tags.empty?
175
+ return tags if JSON.generate(tags).bytesize <= max_chars
176
+
177
+ nil
178
+ end
179
+
180
+ # Drops trailing frames until the serialized array fits `max_chars`,
181
+ # mirroring the JS SDK's `capFrames`/the server's `capFramesToBudget`.
182
+ #
183
+ # Binary-searches the cut point (O(log n) `JSON.generate` calls)
184
+ # instead of popping one frame and re-serializing the whole array per
185
+ # pop (O(n) calls, each itself O(n)) — a deep recursive backtrace can
186
+ # carry thousands of frames, where the naive approach is noticeably
187
+ # slow.
188
+ def cap_frames(frames, max_chars)
189
+ return frames if frames.empty? || JSON.generate(frames).bytesize <= max_chars
190
+
191
+ low = 0
192
+ high = frames.length - 1
193
+ while low < high
194
+ mid = (low + high + 1) / 2
195
+ if JSON.generate(frames.first(mid)).bytesize <= max_chars
196
+ low = mid
197
+ else
198
+ high = mid - 1
199
+ end
200
+ end
201
+ frames.first(low)
202
+ end
203
+
204
+ # Builds the wire `user` object: only `id`/`email` (accepts either
205
+ # symbol or string keys from the caller), each length-capped, matching
206
+ # the server's `.strict()` `errorItemSchema.user` — any other key would
207
+ # get the whole item rejected, so unrecognized keys are dropped rather
208
+ # than forwarded. Returns `nil` for a `nil`/empty input, or if neither
209
+ # recognized key is present, so the caller can omit the wire key.
210
+ def cap_user(user)
211
+ return nil if user.nil? || user.empty?
212
+
213
+ id = user[:id] || user["id"]
214
+ email = user[:email] || user["email"]
215
+ built = {}
216
+ built[:id] = cap_text(id, MAX_USER_FIELD_CHARS) if id
217
+ built[:email] = cap_text(email, MAX_USER_FIELD_CHARS) if email
218
+ built.empty? ? nil : built
219
+ end
220
+
221
+ # Caps breadcrumb count to the server's own ceiling (keeping the most
222
+ # recent ones) and per-breadcrumb message/category length.
223
+ def cap_breadcrumbs(breadcrumbs, max_count)
224
+ return nil if breadcrumbs.nil?
225
+
226
+ breadcrumbs.last(max_count).map do |crumb|
227
+ crumb = crumb.dup
228
+ crumb[:message] = cap_text(crumb[:message], MAX_BREADCRUMB_MESSAGE_CHARS) if crumb[:message]
229
+ crumb[:category] = cap_text(crumb[:category], MAX_BREADCRUMB_CATEGORY_CHARS) if crumb[:category]
230
+ crumb
231
+ end
232
+ end
233
+
234
+ # Caps the custom fingerprint override to the server's own bounds:
235
+ # at most `MAX_FINGERPRINT_ENTRIES` entries, each at most
236
+ # `MAX_FINGERPRINT_CHARS` characters. Returns `nil` for a `nil`/empty
237
+ # input so the caller can omit the wire key.
238
+ def cap_fingerprint(fingerprint)
239
+ return nil if fingerprint.nil? || fingerprint.empty?
240
+
241
+ fingerprint.first(MAX_FINGERPRINT_ENTRIES).map { |part| cap_text(part.to_s, MAX_FINGERPRINT_CHARS) }
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "securerandom"
6
+
7
+ module Alplus
8
+ # Cron/job liveness pings against Monitor's `GET|POST /h/:token`
9
+ # (issue #16; docs/ARCHITECTURE.md §8): the token is the auth (recognized
10
+ # → 202 even paused, unrecognized → 404); `?state=start|finish|fail`, an
11
+ # unrecognized state falls back to `finish` (still records the run,
12
+ # matching the Elixir SDK) rather than silently no-op-ing. Mirrors
13
+ # `packages/sdk/src/core/heartbeat.ts`'s wire contract and reuses `Retry`
14
+ # (issue #15) for backoff/`Retry-After` instead of duplicating it — see
15
+ # `Retry`'s comment on why Ruby shares this where the JS SDK deliberately
16
+ # doesn't.
17
+ #
18
+ # `Alplus.heartbeat` (the public entry point in `alplus.rb`) calls `ping`
19
+ # synchronously on the caller's thread: a cron/job runner is not a web
20
+ # request, and the caller (typically the very last statement of a job)
21
+ # wants the ping actually sent before the process exits, not fired into
22
+ # a background thread that may never run. This DOES block the caller
23
+ # briefly — bounded to `HEARTBEAT_MAX_ATTEMPTS` attempts and a
24
+ # `HEARTBEAT_MAX_RETRY_AFTER_SECONDS` cap on any 429 `Retry-After`
25
+ # (ignoring a server-requested wait beyond that), so a slow ingest
26
+ # endpoint can add at most ~2s, never `Transport`'s full ~30s budget.
27
+ module Heartbeat
28
+ VALID_STATES = %w[start finish fail].freeze
29
+ HEARTBEAT_MAX_ATTEMPTS = 2
30
+ HEARTBEAT_MAX_RETRY_AFTER_SECONDS = 2
31
+
32
+ # Matches the JS SDK's `PING_ID_PATTERN` shape closely enough for a
33
+ # server-side idempotency key; not validated against a caller-supplied
34
+ # override here since nothing in this SDK accepts one from outside yet.
35
+ module_function
36
+
37
+ # Never raises: an internal error, network failure, or retryable
38
+ # non-ok response is retried (bounded, see module doc) and then
39
+ # swallowed, logged via `config.logger` if the retry budget is
40
+ # exhausted. Returns `nil` always.
41
+ #
42
+ # The SAME `ping_id` is reused on every retry attempt within one call
43
+ # (issue #16 defect): ingest dedups a retried ping on `ping_id`, so
44
+ # reusing it — instead of minting a fresh id per HTTP attempt — is what
45
+ # keeps a retried fail/finish from being processed as two separate
46
+ # events (a duplicate incident/notification).
47
+ def ping(token, state: "finish", config: Alplus.configuration, sleeper: method(:sleep), ping_id: nil)
48
+ resolved_state = VALID_STATES.include?(state.to_s) ? state.to_s : "finish"
49
+ resolved_ping_id = ping_id || generate_ping_id
50
+ uri = build_uri(token, resolved_state, resolved_ping_id, config)
51
+
52
+ result = Retry.perform(
53
+ sleeper: sleeper,
54
+ max_attempts: HEARTBEAT_MAX_ATTEMPTS,
55
+ max_retry_after_seconds: HEARTBEAT_MAX_RETRY_AFTER_SECONDS
56
+ ) { |_attempt| post(uri, config) }
57
+
58
+ if result.outcome == :exhausted
59
+ detail = result.error ? "#{result.error.class}: #{result.error.message}" : "status #{result.response&.code}"
60
+ config.logger&.warn("[alplus] heartbeat exhausted #{HEARTBEAT_MAX_ATTEMPTS} attempt(s) (token #{token.inspect}, state #{resolved_state.inspect}): #{detail}")
61
+ end
62
+ nil
63
+ rescue StandardError => e
64
+ config.logger&.warn("[alplus] heartbeat failed internally: #{e.class}: #{e.message}")
65
+ nil
66
+ end
67
+
68
+ def generate_ping_id
69
+ SecureRandom.uuid
70
+ end
71
+
72
+ # Exposed for tests asserting the exact URL shape; not otherwise part
73
+ # of the public surface.
74
+ def build_uri(token, state, ping_id, config)
75
+ base = config.endpoint.to_s.sub(%r{/+\z}, "")
76
+ uri = URI.parse("#{base}/h/#{URI.encode_www_form_component(token)}")
77
+ uri.query = URI.encode_www_form(state: state, ping_id: ping_id)
78
+ uri
79
+ end
80
+
81
+ def post(uri, config)
82
+ http = Net::HTTP.new(uri.host, uri.port)
83
+ http.use_ssl = uri.scheme == "https"
84
+ http.open_timeout = config.open_timeout
85
+ http.read_timeout = config.read_timeout
86
+
87
+ http.request(Net::HTTP::Post.new(uri.request_uri))
88
+ end
89
+ end
90
+ end
data/lib/alplus/id.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Alplus
6
+ # Client-side event id generation for Observe. `POST /e/errors` treats
7
+ # `items[].id` as the idempotency key and requires it to be generated
8
+ # client-side, `err_`-prefixed, before the event leaves the process.
9
+ #
10
+ # This mirrors packages/sdk/src/core/id.ts (TypeScript SDK) byte-for-byte
11
+ # in shape: a time-ordered UUIDv7 (RFC 9562) — 48-bit millisecond Unix
12
+ # timestamp, version nibble (0111), 74 bits of randomness, variant bits (10).
13
+ module Id
14
+ module_function
15
+
16
+ def uuidv7
17
+ bytes = SecureRandom.random_bytes(16).bytes
18
+ ts = (Time.now.to_f * 1000).to_i
19
+
20
+ bytes[0] = (ts >> 40) & 0xff
21
+ bytes[1] = (ts >> 32) & 0xff
22
+ bytes[2] = (ts >> 24) & 0xff
23
+ bytes[3] = (ts >> 16) & 0xff
24
+ bytes[4] = (ts >> 8) & 0xff
25
+ bytes[5] = ts & 0xff
26
+ bytes[6] = 0x70 | (bytes[6] & 0x0f) # version 7
27
+ bytes[8] = 0x80 | (bytes[8] & 0x3f) # variant 10
28
+
29
+ hex = bytes.map { |b| format("%02x", b) }.join
30
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
31
+ end
32
+
33
+ # Generates the `err_`-prefixed, client-generated UUIDv7 every Observe
34
+ # event carries — the same prefix/shape the JS and Elixir SDKs emit.
35
+ def generate_event_id
36
+ "err_#{uuidv7}"
37
+ end
38
+
39
+ # Generates a `ses_`-prefixed UUIDv7 session id for the
40
+ # `POST /e/sessions` wire protocol (issue #12). Opaque and used only
41
+ # for in-window ingest dedup — never persisted past that, never a PII
42
+ # carrier.
43
+ def generate_session_id
44
+ "ses_#{uuidv7}"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alplus
4
+ # Records application log lines as breadcrumbs (issue #47): every
5
+ # `Logger#add` on an attached logger becomes a `log`-category breadcrumb
6
+ # on the current thread's `Scope` ring (before-error context) and is
7
+ # offered to any pending exception item inside its post-error log window
8
+ # (after-error context). Attached to `Rails.logger` by the Railtie;
9
+ # usable on any stdlib-compatible logger via `attach`.
10
+ #
11
+ # Excluded on purpose: the SDK's own `[alplus]` diagnostics (the timeline
12
+ # must hold the application's output, not the SDK talking to itself), and
13
+ # non-String messages (the block form is not evaluated here — evaluating
14
+ # it early would change the host's lazy-logging semantics).
15
+ module LoggerBreadcrumbs
16
+ SEVERITY_LEVEL = {
17
+ 0 => "debug", 1 => "info", 2 => "warning", 3 => "error", 4 => "fatal"
18
+ }.freeze
19
+ SDK_INTERNAL_PREFIX = "[alplus]"
20
+
21
+ # Prepended onto the logger's singleton class so both a plain
22
+ # `::Logger` and Rails' `ActiveSupport::BroadcastLogger` (whose `add`
23
+ # fans out to its broadcasts) are covered by one seam.
24
+ module Recorder
25
+ def add(severity, message = nil, progname = nil, &block)
26
+ Alplus::LoggerBreadcrumbs.record(severity, message, progname)
27
+ super
28
+ end
29
+ end
30
+
31
+ module_function
32
+
33
+ def attach(logger)
34
+ return if logger.nil? || logger.singleton_class.ancestors.include?(Recorder)
35
+
36
+ logger.singleton_class.prepend(Recorder)
37
+ end
38
+
39
+ # Never raises, and never re-enters (a breadcrumb path that itself logs
40
+ # would otherwise recurse through the patched `add`).
41
+ def record(severity, message, progname)
42
+ config = Alplus.configuration
43
+ return unless config&.logger_breadcrumbs_enabled
44
+ return if Thread.current[:alplus_recording_log_breadcrumb]
45
+
46
+ text = message.is_a?(String) ? message : (progname if progname.is_a?(String) && message.nil?)
47
+ return if text.nil? || text.empty? || text.start_with?(SDK_INTERNAL_PREFIX)
48
+
49
+ Thread.current[:alplus_recording_log_breadcrumb] = true
50
+ level = SEVERITY_LEVEL.fetch(severity, "info")
51
+ Scope.current.add_breadcrumb(category: "log", message: text, level: level)
52
+ Alplus.initialized_client&.notify_log_breadcrumb(
53
+ category: "log",
54
+ message: Envelope.cap_text(text, Envelope::MAX_BREADCRUMB_MESSAGE_CHARS),
55
+ level: level,
56
+ ts: Time.now.utc.iso8601(3)
57
+ )
58
+ rescue StandardError
59
+ nil
60
+ ensure
61
+ Thread.current[:alplus_recording_log_breadcrumb] = nil
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alplus
4
+ # Auto-breadcrumbs from `ActiveSupport::Notifications` (Rails only):
5
+ # pushes a small, bounded set of framework events into the current
6
+ # request's `Scope` as breadcrumbs, so a captured exception's timeline
7
+ # shows "what happened just before this" without the host app calling
8
+ # `Alplus.add_breadcrumb` by hand (mirrors Sentry's/AppSignal's
9
+ # instrumentation breadcrumbs).
10
+ #
11
+ # Wired from `Railtie#install!` (guarded there too) -- a total no-op
12
+ # outside Rails/`ActiveSupport`, and never subscribed twice even if a
13
+ # host process boots more than one `Rails::Application` (test suites do
14
+ # this routinely).
15
+ #
16
+ # Deliberately a SMALL, fixed event list: more events means more noise
17
+ # per request and a bigger cut of the `Scope`'s bounded breadcrumb ring
18
+ # buffer (`Scope::MAX_BREADCRUMBS`) spent on framework chatter instead of
19
+ # the host app's own `add_breadcrumb` calls.
20
+ module NotificationsSubscriber
21
+ EVENTS = %w[sql.active_record process_action.action_controller start_processing.action_controller].freeze
22
+
23
+ class << self
24
+ # Idempotent: a second call (e.g. a second `Rails::Application` boot
25
+ # in the same process, common in test suites) is a no-op.
26
+ def install!
27
+ return if @installed
28
+ return unless defined?(::ActiveSupport::Notifications)
29
+
30
+ EVENTS.each do |event_name|
31
+ ::ActiveSupport::Notifications.subscribe(event_name) do |*args|
32
+ handle(event_name, ::ActiveSupport::Notifications::Event.new(*args))
33
+ end
34
+ end
35
+ @installed = true
36
+ end
37
+
38
+ # Test-only: lets a spec re-install (e.g. against a stubbed
39
+ # `Scope`/`Configuration`) without carrying state from a previous
40
+ # example. Not called by production code.
41
+ def reset!
42
+ @installed = false
43
+ end
44
+
45
+ # Fail-safe: instrumentation must never break the request it is
46
+ # observing. Any error here (a payload shape this Rails version
47
+ # doesn't produce, a `Scope` write racing shutdown, etc.) is
48
+ # swallowed.
49
+ def handle(event_name, notification)
50
+ return unless breadcrumbs_enabled?
51
+
52
+ breadcrumb = build_breadcrumb(event_name, notification)
53
+ return unless breadcrumb
54
+
55
+ Scope.current.add_breadcrumb(**breadcrumb)
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ private
61
+
62
+ def breadcrumbs_enabled?
63
+ Alplus.configuration.breadcrumbs_enabled
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def build_breadcrumb(event_name, notification)
69
+ case event_name
70
+ when "sql.active_record"
71
+ sql_breadcrumb(notification)
72
+ when "process_action.action_controller"
73
+ process_action_breadcrumb(notification)
74
+ when "start_processing.action_controller"
75
+ start_processing_breadcrumb(notification)
76
+ end
77
+ end
78
+
79
+ # `payload[:binds]` (the actual bound parameter VALUES -- e.g. a
80
+ # looked-up email or password hash) is deliberately never read here.
81
+ # Only the query name and the (already-parameterized, `?`-holed)
82
+ # `payload[:sql]` text are kept.
83
+ def sql_breadcrumb(notification)
84
+ payload = notification.payload
85
+ return nil if payload[:name] == "SCHEMA"
86
+
87
+ { message: payload[:sql].to_s, category: "query", level: "info",
88
+ data: { name: payload[:name], duration_ms: notification.duration.round(1) }.compact }
89
+ end
90
+
91
+ def process_action_breadcrumb(notification)
92
+ payload = notification.payload
93
+ { message: "#{payload[:controller]}##{payload[:action]}", category: "http", level: "info",
94
+ data: { status: payload[:status], method: payload[:method] }.compact }
95
+ end
96
+
97
+ # No request params in the breadcrumb data: params routinely carry
98
+ # user-entered PII, and this event fires before the built-in
99
+ # scrubber (`Scrubber`, which only walks `context`/`contexts`/
100
+ # `tags`/`user` at capture time) ever sees it.
101
+ def start_processing_breadcrumb(notification)
102
+ payload = notification.payload
103
+ { message: "#{payload[:method]} #{payload[:path]}", category: "http", level: "info" }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alplus
4
+ # The post-error log window (issue #47): a built exception item is held
5
+ # here for a short, bounded window before delivery, and log-line
6
+ # breadcrumbs recorded on the SAME thread during the window are appended
7
+ # to it, marked `data: { after_error: true }`.
8
+ #
9
+ # Same-thread attribution is the correctness rule: under a concurrent
10
+ # server, another request's log lines must never pollute this error's
11
+ # timeline. Rails logs the unhandled exception (DebugExceptions) on the
12
+ # request thread AFTER `RackMiddleware` re-raises, which is exactly the
13
+ # window this class keeps open — a request-end seal would run too early
14
+ # to see that line.
15
+ #
16
+ # One lazily-spawned sealer thread sleeps until the earliest deadline and
17
+ # seals due entries; it exits when the list drains and respawns on the
18
+ # next hold. `seal_all!` (called by `Client#flush`/`#close`) seals
19
+ # everything immediately — the window delays delivery, never loses events.
20
+ class PendingWindow
21
+ MAX_AFTER_ERROR_BREADCRUMBS = 20
22
+ # The server ceiling (`Envelope::SERVER_MAX_BREADCRUMBS`).
23
+ MAX_TOTAL_BREADCRUMBS = Envelope::SERVER_MAX_BREADCRUMBS
24
+
25
+ Entry = Struct.new(:item, :thread, :deadline, :appended, keyword_init: true)
26
+
27
+ def initialize(window_ms, &deliver)
28
+ @window_ms = window_ms
29
+ @deliver = deliver
30
+ @entries = []
31
+ @mutex = Mutex.new
32
+ @sealer = nil
33
+ end
34
+
35
+ def enabled?
36
+ @window_ms.positive?
37
+ end
38
+
39
+ def hold(item)
40
+ entry = Entry.new(item: item, thread: Thread.current, deadline: monotonic_now + (@window_ms / 1000.0), appended: 0)
41
+ @mutex.synchronize { @entries << entry }
42
+ ensure_sealer_running
43
+ end
44
+
45
+ # Appends a log-line breadcrumb to every pending entry captured on the
46
+ # calling thread, within the per-entry and total bounds. Never raises.
47
+ def notify_log_breadcrumb(crumb)
48
+ @mutex.synchronize do
49
+ @entries.each do |entry|
50
+ next unless entry.thread == Thread.current
51
+ next if entry.appended >= MAX_AFTER_ERROR_BREADCRUMBS
52
+
53
+ crumbs = (entry.item[:breadcrumbs] ||= [])
54
+ next if crumbs.length >= MAX_TOTAL_BREADCRUMBS
55
+
56
+ crumbs << crumb.merge(data: (crumb[:data] || {}).merge(after_error: true))
57
+ entry.appended += 1
58
+ end
59
+ end
60
+ nil
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ def seal_all!
66
+ entries = @mutex.synchronize { @entries.slice!(0..) }
67
+ entries.each { |entry| @deliver.call(entry.item) }
68
+ end
69
+
70
+ private
71
+
72
+ def monotonic_now
73
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ end
75
+
76
+ def ensure_sealer_running
77
+ @mutex.synchronize do
78
+ next if @sealer&.alive?
79
+
80
+ @sealer = Thread.new { sealer_loop }
81
+ @sealer.name = "alplus-pending-sealer" if @sealer.respond_to?(:name=)
82
+ end
83
+ end
84
+
85
+ def sealer_loop
86
+ loop do
87
+ due, sleep_for = @mutex.synchronize do
88
+ now = monotonic_now
89
+ due_entries = @entries.select { |entry| entry.deadline <= now }
90
+ @entries -= due_entries
91
+ next_deadline = @entries.map(&:deadline).min
92
+ [due_entries, next_deadline && (next_deadline - now)]
93
+ end
94
+
95
+ due.each { |entry| @deliver.call(entry.item) }
96
+ break if sleep_for.nil?
97
+
98
+ sleep([sleep_for, 0.01].max)
99
+ end
100
+ rescue StandardError
101
+ # The sealer must never crash the host app; entries left behind are
102
+ # still delivered by the next `seal_all!` (flush/close).
103
+ nil
104
+ end
105
+ end
106
+ end