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