realuptime-errors 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,489 @@
1
+ # frozen_string_literal: true
2
+
3
+ # realuptime-errors: zero-dependency Ruby error tracking SDK for RealUptime
4
+ # Errors (docs/errors-plan.md, Linear REA-255).
5
+ #
6
+ # One wire contract with the JS and Python SDKs (packages/errors-js/types.ts);
7
+ # the shared scrub vectors (scrub-vectors.json, vendored into this gem and
8
+ # asserted byte-identical to packages/errors-js/scrub-vectors.json) pin all
9
+ # three SDKs and the server's second net to identical scrubbing. Ruby
10
+ # standard library only (json, net/http, uri, time, rbconfig): this code
11
+ # runs inside customer processes and its dependency graph must be
12
+ # auditable at a glance, enforced by test/wire_contract_test.rb.
13
+ #
14
+ # Contract with the host app, non-negotiable: THIS SDK NEVER RAISES out of
15
+ # a public method. A broken SDK logs once and goes quiet; an error tracker
16
+ # that crashes the app it watches is worse than none.
17
+ #
18
+ # Usage:
19
+ #
20
+ # require "realuptime/errors"
21
+ # Realuptime::Errors.init(dsn: "https://realuptime.io/api/errors/v1/ingest/rue_...",
22
+ # release: "v2.4.1", environment: "production")
23
+ # ...
24
+ # Realuptime::Errors.capture_exception(error)
25
+ #
26
+ # Rack: use Realuptime::Errors::RackMiddleware
27
+ # Rails: require "realuptime/errors/rails" (Railtie installs everything)
28
+ # Sidekiq: require "realuptime/errors/sidekiq"; Realuptime::Errors::Sidekiq.install
29
+
30
+ require "rbconfig"
31
+ require "realuptime/errors/version"
32
+ require "realuptime/errors/scrub"
33
+
34
+ module Realuptime
35
+ module Errors
36
+ # Mirrored from packages/errors-js/types.ts and pinned by
37
+ # test/wire_contract_test.rb.
38
+ MAX_EVENTS_PER_BATCH = 50
39
+ MAX_MESSAGE_LENGTH = 4000
40
+ MAX_FRAMES_PER_EVENT = 50
41
+ MAX_STRING_LENGTH = 512
42
+ MAX_BREADCRUMBS_PER_EVENT = 20
43
+ MAX_BREADCRUMB_DATA_ENTRIES = 10
44
+ BUFFER_MAX = 200
45
+
46
+ # v2 caps (REA-182), mirrored from packages/errors-js/types.ts.
47
+ MAX_TAGS_PER_EVENT = 20
48
+ MAX_CONTEXT_ENTRIES = 20
49
+ MAX_CONTEXT_KEY_LENGTH = 64
50
+ MAX_LOCAL_VARS_PER_FRAME = 20
51
+ MAX_LOCAL_VAR_LENGTH = 256
52
+
53
+ SEND_TIMEOUT_S = 10.0
54
+ BACKOFF_START_S = 5.0
55
+ BACKOFF_MAX_S = 300.0
56
+
57
+ SCRUBBED = Scrub::SCRUBBED
58
+
59
+ USER_KEYS = %w[id email username].freeze
60
+
61
+ # Everything init() keeps. Breadcrumbs and the v2 sticky scope are
62
+ # bounded at WRITE time so a long-lived process cannot grow them without
63
+ # limit; the eviction count rides every event as breadcrumbsDropped.
64
+ class State
65
+ attr_reader :dsn, :release, :environment, :allow_fields, :transport, :device, :lock
66
+ attr_accessor :breadcrumbs, :breadcrumbs_evicted, :user, :tags, :context
67
+
68
+ def initialize(dsn:, release:, environment:, allow_fields:, transport:, device:)
69
+ @dsn = dsn
70
+ @release = release
71
+ @environment = environment
72
+ @allow_fields = allow_fields
73
+ @transport = transport
74
+ @device = device
75
+ @lock = Mutex.new
76
+ @breadcrumbs = []
77
+ @breadcrumbs_evicted = 0
78
+ @user = nil
79
+ @tags = {}
80
+ @context = {}
81
+ end
82
+ end
83
+
84
+ @state = nil
85
+ @at_exit_installed = false
86
+
87
+ class << self
88
+ # Initializes the SDK. Safe to call twice (last call wins); a missing
89
+ # DSN logs once and stays inert. Never raises.
90
+ #
91
+ # dsn the project's ingest URL (required)
92
+ # release e.g. a git SHA or version tag
93
+ # environment e.g. "production"
94
+ # allow_fields per-field opt-back-in: lowercased header names
95
+ # ("x-request-id") and the dotted identity names
96
+ # "user.email" / "user.username". Never a global
97
+ # switch.
98
+ # capture_unhandled installs an at_exit hook that reports the
99
+ # exception a process is dying from (default on)
100
+ # send_device_info runtime/platform facts about THIS process,
101
+ # never hostname or IP (default on)
102
+ # background deliver on a background thread (default on);
103
+ # false delivers inline on every capture
104
+ #
105
+ # Keyword seams opener:/now:/log: exist for tests.
106
+ def init(dsn: nil, release: nil, environment: nil, allow_fields: nil, capture_unhandled: true,
107
+ send_device_info: true, background: true, opener: nil, now: nil, log: nil)
108
+ unless dsn.is_a?(String) && !dsn.empty?
109
+ safe_log(log, "[realuptime-errors] init called without a dsn; error reporting is disabled.")
110
+ return nil
111
+ end
112
+ transport = Transport.new(dsn, opener: opener, now: now, log: log, background: background)
113
+ @state = State.new(
114
+ dsn: dsn,
115
+ release: release,
116
+ environment: environment,
117
+ allow_fields: Array(allow_fields).map(&:to_s),
118
+ transport: transport,
119
+ device: send_device_info ? detect_device : nil
120
+ )
121
+ install_at_exit if capture_unhandled
122
+ nil
123
+ rescue StandardError => e
124
+ safe_log(log, "[realuptime-errors] init failed: #{e.class}: #{e.message}")
125
+ nil
126
+ end
127
+
128
+ def initialized?
129
+ !@state.nil?
130
+ end
131
+
132
+ # Reports an exception. Never raises. user/tags/context are per-capture
133
+ # overrides merged OVER the sticky scope set by set_user/set_tag/
134
+ # set_context.
135
+ def capture_exception(exc, request: nil, fingerprint: nil, release: nil, environment: nil,
136
+ user: nil, tags: nil, context: nil)
137
+ state = @state
138
+ return nil if state.nil?
139
+
140
+ event = if exc.is_a?(Exception)
141
+ message = exc.message.to_s
142
+ message = exc.class.name.to_s if message.empty?
143
+ build_event(message, exc.class.name.to_s, frames_from_exception(exc),
144
+ request: request, fingerprint: fingerprint, release: release,
145
+ environment: environment, user: user, tags: tags, context: context)
146
+ else
147
+ build_event(exc.to_s, nil, nil, request: request, fingerprint: fingerprint,
148
+ release: release, environment: environment,
149
+ user: user, tags: tags, context: context)
150
+ end
151
+ state.transport.enqueue(Scrub.scrub_event(event, state.allow_fields))
152
+ nil
153
+ rescue StandardError => e
154
+ safe_log(nil, "[realuptime-errors] capture_exception failed: #{e.class}: #{e.message}")
155
+ nil
156
+ end
157
+
158
+ # Reports a plain message. Never raises.
159
+ def capture_message(message, request: nil, fingerprint: nil, release: nil, environment: nil,
160
+ user: nil, tags: nil, context: nil)
161
+ state = @state
162
+ return nil if state.nil?
163
+
164
+ event = build_event(message.to_s, nil, nil, request: request, fingerprint: fingerprint,
165
+ release: release, environment: environment,
166
+ user: user, tags: tags, context: context)
167
+ state.transport.enqueue(Scrub.scrub_event(event, state.allow_fields))
168
+ nil
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # Records one breadcrumb onto the bounded trail. Rides the NEXT captured
174
+ # event, newest last, at most MAX_BREADCRUMBS_PER_EVENT; older entries
175
+ # are evicted and the eviction count rides as breadcrumbsDropped.
176
+ def add_breadcrumb(message, category: nil, data: nil)
177
+ state = @state
178
+ return nil if state.nil? || !message.is_a?(String)
179
+
180
+ crumb_data = nil
181
+ if data.is_a?(Hash)
182
+ crumb_data = {}
183
+ data.first(MAX_BREADCRUMB_DATA_ENTRIES).each do |name, value|
184
+ crumb_data[clip(name.to_s, MAX_STRING_LENGTH)] = clip(value, MAX_STRING_LENGTH) if value.is_a?(String)
185
+ end
186
+ end
187
+ crumb = {
188
+ "timestamp" => iso_time(now_f),
189
+ "category" => category.is_a?(String) ? clip(category, 100) : nil,
190
+ "message" => clip(message, MAX_STRING_LENGTH),
191
+ "data" => crumb_data
192
+ }
193
+ state.lock.synchronize do
194
+ state.breadcrumbs << crumb
195
+ if state.breadcrumbs.length > MAX_BREADCRUMBS_PER_EVENT
196
+ state.breadcrumbs.shift
197
+ state.breadcrumbs_evicted += 1
198
+ end
199
+ end
200
+ nil
201
+ rescue StandardError
202
+ nil
203
+ end
204
+
205
+ # Sticky identity applied to every subsequent event; nil clears it.
206
+ # Only id / email / username are carried. email and username are
207
+ # "[scrubbed]" before serialization unless the matching allow_fields
208
+ # entry is set. Never raises.
209
+ def set_user(user)
210
+ state = @state
211
+ return nil if state.nil?
212
+
213
+ next_user = nil
214
+ if user.is_a?(Hash)
215
+ next_user = {}
216
+ USER_KEYS.each do |key|
217
+ value = user[key] || user[key.to_sym]
218
+ next_user[key] = clip(value, MAX_STRING_LENGTH) if value.is_a?(String)
219
+ end
220
+ next_user = nil if next_user.empty?
221
+ end
222
+ state.lock.synchronize { state.user = next_user }
223
+ nil
224
+ rescue StandardError
225
+ nil
226
+ end
227
+
228
+ # One sticky tag; nil removes it. Tags past MAX_TAGS_PER_EVENT are
229
+ # ignored rather than evicting an existing one. Never raises.
230
+ def set_tag(key, value)
231
+ state = @state
232
+ return nil if state.nil?
233
+
234
+ key = key.to_s
235
+ return nil if key.empty?
236
+
237
+ name = clip(key, MAX_CONTEXT_KEY_LENGTH)
238
+ state.lock.synchronize do
239
+ if value.nil?
240
+ state.tags.delete(name)
241
+ elsif value.is_a?(String)
242
+ unless !state.tags.key?(name) && state.tags.length >= MAX_TAGS_PER_EVENT
243
+ state.tags[name] = clip(value, MAX_STRING_LENGTH)
244
+ end
245
+ end
246
+ end
247
+ nil
248
+ rescue StandardError
249
+ nil
250
+ end
251
+
252
+ def set_tags(tags)
253
+ return nil unless tags.is_a?(Hash)
254
+
255
+ tags.each { |name, value| set_tag(name, value) }
256
+ nil
257
+ rescue StandardError
258
+ nil
259
+ end
260
+
261
+ # One sticky custom-context entry, or a whole Hash merged in.
262
+ # set_context(nil) clears everything. Never raises.
263
+ def set_context(key, value = nil)
264
+ state = @state
265
+ return nil if state.nil?
266
+
267
+ if key.nil?
268
+ state.lock.synchronize { state.context = {} }
269
+ return nil
270
+ end
271
+ if key.is_a?(Hash)
272
+ key.each { |name, entry| set_context(name, entry) }
273
+ return nil
274
+ end
275
+ key = key.to_s
276
+ return nil if key.empty?
277
+
278
+ name = clip(key, MAX_CONTEXT_KEY_LENGTH)
279
+ state.lock.synchronize do
280
+ if value.nil?
281
+ state.context.delete(name)
282
+ elsif value.is_a?(String)
283
+ unless !state.context.key?(name) && state.context.length >= MAX_CONTEXT_ENTRIES
284
+ state.context[name] = clip(value, MAX_STRING_LENGTH)
285
+ end
286
+ end
287
+ end
288
+ nil
289
+ rescue StandardError
290
+ nil
291
+ end
292
+
293
+ # Delivers anything buffered, synchronously. Never raises.
294
+ def flush
295
+ @state&.transport&.flush
296
+ nil
297
+ rescue StandardError
298
+ nil
299
+ end
300
+
301
+ # Test seam: drops state and stops the delivery thread.
302
+ def close
303
+ state = @state
304
+ @state = nil
305
+ state&.transport&.close
306
+ nil
307
+ rescue StandardError
308
+ nil
309
+ end
310
+
311
+ # Exposed for tests and adapters.
312
+ def transport
313
+ @state&.transport
314
+ end
315
+
316
+ # Builds one wire event (string-keyed Hash). v2 fields are emitted only
317
+ # when NON-EMPTY, so an integration that never touches the v2 API keeps
318
+ # producing byte-identical v1 payloads.
319
+ def build_event(message, exception_type, frames, request: nil, fingerprint: nil, release: nil,
320
+ environment: nil, user: nil, tags: nil, context: nil)
321
+ state = @state
322
+ breadcrumbs = nil
323
+ breadcrumbs_dropped = 0
324
+ scope_user = nil
325
+ scope_tags = {}
326
+ scope_context = {}
327
+ device = nil
328
+ if state
329
+ state.lock.synchronize do
330
+ breadcrumbs = state.breadcrumbs.dup unless state.breadcrumbs.empty?
331
+ breadcrumbs_dropped = state.breadcrumbs_evicted
332
+ scope_user = state.user.dup if state.user
333
+ scope_tags = state.tags.dup
334
+ scope_context = state.context.dup
335
+ device = state.device.dup if state.device
336
+ end
337
+ end
338
+ event = {
339
+ "occurredAt" => iso_time(now_f),
340
+ "message" => clip(message.to_s, MAX_MESSAGE_LENGTH),
341
+ "exceptionType" => exception_type ? clip(exception_type, MAX_STRING_LENGTH) : nil,
342
+ "release" => release || state&.release,
343
+ "environment" => environment || state&.environment,
344
+ "frames" => frames,
345
+ "request" => normalize_request(request),
346
+ "fingerprint" => fingerprint,
347
+ "breadcrumbs" => breadcrumbs,
348
+ "breadcrumbsDropped" => breadcrumbs_dropped
349
+ }
350
+ merged_user = scope_user
351
+ if user.is_a?(Hash)
352
+ merged_user = (scope_user || {}).dup
353
+ USER_KEYS.each do |key|
354
+ value = user[key] || user[key.to_sym]
355
+ merged_user[key] = clip(value, MAX_STRING_LENGTH) if value.is_a?(String)
356
+ end
357
+ end
358
+ event["user"] = merged_user if merged_user && !merged_user.empty?
359
+ merged_tags = scope_tags.merge(bounded_map(tags, MAX_TAGS_PER_EVENT))
360
+ event["tags"] = merged_tags unless merged_tags.empty?
361
+ merged_context = scope_context.merge(bounded_map(context, MAX_CONTEXT_ENTRIES))
362
+ event["context"] = merged_context unless merged_context.empty?
363
+ event["device"] = device if device
364
+ event
365
+ end
366
+
367
+ # Wire frames from a Ruby exception, innermost first (Ruby's own
368
+ # backtrace order), capped at MAX_FRAMES_PER_EVENT. inApp is a
369
+ # best-effort guess: not under a gems directory, not the Ruby stdlib,
370
+ # not an <internal:...> frame.
371
+ def frames_from_exception(exc)
372
+ locations = exc.backtrace_locations
373
+ frames = if locations
374
+ locations.first(MAX_FRAMES_PER_EVENT).map do |loc|
375
+ path = loc.absolute_path || loc.path || ""
376
+ {
377
+ "file" => clip(path, MAX_STRING_LENGTH),
378
+ "function" => loc.label ? clip(loc.label, MAX_STRING_LENGTH) : nil,
379
+ "line" => loc.lineno,
380
+ "inApp" => in_app?(path)
381
+ }
382
+ end
383
+ else
384
+ (exc.backtrace || []).first(MAX_FRAMES_PER_EVENT).map { |line| parse_backtrace_line(line) }
385
+ end
386
+ frames.empty? ? nil : frames
387
+ rescue StandardError
388
+ nil
389
+ end
390
+
391
+ def in_app?(path)
392
+ return false if path.empty? || path.start_with?("<internal:")
393
+ return false if path.include?("/gems/") || path.include?("/bundler/gems/")
394
+
395
+ rubylib = RbConfig::CONFIG["rubylibdir"]
396
+ return false if rubylib && !rubylib.empty? && path.start_with?(rubylib)
397
+
398
+ true
399
+ end
400
+
401
+ private
402
+
403
+ def parse_backtrace_line(line)
404
+ match = /\A(.*?):(\d+)(?::in [`'](.*)')?\z/.match(line.to_s)
405
+ if match
406
+ { "file" => clip(match[1], MAX_STRING_LENGTH), "function" => match[3] ? clip(match[3], MAX_STRING_LENGTH) : nil,
407
+ "line" => match[2].to_i, "inApp" => in_app?(match[1]) }
408
+ else
409
+ { "file" => clip(line.to_s, MAX_STRING_LENGTH), "function" => nil, "line" => nil, "inApp" => false }
410
+ end
411
+ end
412
+
413
+ # v2: the runtime facts, read out of this process. Never the hostname,
414
+ # the local IP or a container id.
415
+ def detect_device
416
+ device = {
417
+ "runtime" => "ruby",
418
+ "runtimeVersion" => RUBY_VERSION.to_s,
419
+ "platform" => clip(RbConfig::CONFIG["host_os"].to_s, MAX_STRING_LENGTH)
420
+ }
421
+ arch = RbConfig::CONFIG["host_cpu"]
422
+ device["arch"] = clip(arch.to_s, MAX_STRING_LENGTH) if arch && !arch.to_s.empty?
423
+ device
424
+ rescue StandardError
425
+ nil
426
+ end
427
+
428
+ def install_at_exit
429
+ return if @at_exit_installed
430
+
431
+ @at_exit_installed = true
432
+ at_exit do
433
+ begin
434
+ error = $!
435
+ capture_exception(error) if error.is_a?(Exception) && !error.is_a?(SystemExit)
436
+ flush
437
+ rescue StandardError
438
+ nil
439
+ end
440
+ end
441
+ end
442
+
443
+ def normalize_request(request)
444
+ return nil unless request.is_a?(Hash)
445
+
446
+ request.each_with_object({}) do |(key, value), out|
447
+ out[key.to_s] = value.is_a?(Hash) ? value.each_with_object({}) { |(k, v), h| h[k.to_s] = v } : value
448
+ end
449
+ end
450
+
451
+ def bounded_map(value, limit)
452
+ out = {}
453
+ return out unless value.is_a?(Hash)
454
+
455
+ value.each do |name, entry|
456
+ break if out.length >= limit
457
+
458
+ name = name.to_s
459
+ next if name.empty? || !entry.is_a?(String)
460
+
461
+ out[clip(name, MAX_CONTEXT_KEY_LENGTH)] = clip(entry, MAX_STRING_LENGTH)
462
+ end
463
+ out
464
+ end
465
+
466
+ def clip(value, limit)
467
+ value.length > limit ? value[0, limit] : value
468
+ end
469
+
470
+ def now_f
471
+ transport = @state&.transport
472
+ transport ? transport.instance_variable_get(:@now).call : Time.now.to_f
473
+ end
474
+
475
+ def iso_time(seconds)
476
+ Time.at(seconds).utc.strftime("%Y-%m-%dT%H:%M:%S") + ".000Z"
477
+ end
478
+
479
+ def safe_log(log, message)
480
+ (log || ->(m) { warn(m) }).call(message)
481
+ rescue StandardError
482
+ nil
483
+ end
484
+ end
485
+ end
486
+ end
487
+
488
+ require "realuptime/errors/transport"
489
+ require "realuptime/errors/rack"