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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/lib/realuptime/errors/rack.rb +73 -0
- data/lib/realuptime/errors/rails.rb +144 -0
- data/lib/realuptime/errors/scrub.rb +150 -0
- data/lib/realuptime/errors/sidekiq.rb +75 -0
- data/lib/realuptime/errors/transport.rb +257 -0
- data/lib/realuptime/errors/version.rb +10 -0
- data/lib/realuptime/errors.rb +489 -0
- data/scrub-vectors.json +506 -0
- metadata +60 -0
|
@@ -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"
|