Module: Realuptime::Errors

Defined in:
lib/realuptime/errors.rb,
lib/realuptime/errors/rack.rb,
lib/realuptime/errors/rails.rb,
lib/realuptime/errors/scrub.rb,
lib/realuptime/errors/sidekiq.rb,
lib/realuptime/errors/version.rb,
lib/realuptime/errors/transport.rb

Defined Under Namespace

Modules: Rails, Scrub, Sidekiq Classes: RackMiddleware, State, Transport

Constant Summary collapse

MAX_EVENTS_PER_BATCH =

Mirrored from packages/errors-js/types.ts and pinned by test/wire_contract_test.rb.

50
MAX_MESSAGE_LENGTH =
4000
MAX_FRAMES_PER_EVENT =
50
MAX_STRING_LENGTH =
512
MAX_BREADCRUMBS_PER_EVENT =
20
MAX_BREADCRUMB_DATA_ENTRIES =
10
BUFFER_MAX =
200
MAX_TAGS_PER_EVENT =

v2 caps (REA-182), mirrored from packages/errors-js/types.ts.

20
MAX_CONTEXT_ENTRIES =
20
MAX_CONTEXT_KEY_LENGTH =
64
MAX_LOCAL_VARS_PER_FRAME =
20
MAX_LOCAL_VAR_LENGTH =
256
SEND_TIMEOUT_S =
10.0
BACKOFF_START_S =
5.0
BACKOFF_MAX_S =
300.0
SCRUBBED =
Scrub::SCRUBBED
USER_KEYS =
%w[id email username].freeze
SDK_NAME =
"realuptime-errors-ruby"
SDK_VERSION =

The string every batch carries on the wire ("sdk" field); the gemspec and the public mirror's version both read this constant.

"0.1.0"

Class Method Summary collapse

Class Method Details

.add_breadcrumb(message, category: nil, data: nil) ⇒ Object

Records one breadcrumb onto the bounded trail. Rides the NEXT captured event, newest last, at most MAX_BREADCRUMBS_PER_EVENT; older entries are evicted and the eviction count rides as breadcrumbsDropped.



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/realuptime/errors.rb', line 176

def add_breadcrumb(message, category: nil, data: nil)
  state = @state
  return nil if state.nil? || !message.is_a?(String)

  crumb_data = nil
  if data.is_a?(Hash)
    crumb_data = {}
    data.first(MAX_BREADCRUMB_DATA_ENTRIES).each do |name, value|
      crumb_data[clip(name.to_s, MAX_STRING_LENGTH)] = clip(value, MAX_STRING_LENGTH) if value.is_a?(String)
    end
  end
  crumb = {
    "timestamp" => iso_time(now_f),
    "category" => category.is_a?(String) ? clip(category, 100) : nil,
    "message" => clip(message, MAX_STRING_LENGTH),
    "data" => crumb_data
  }
  state.lock.synchronize do
    state.breadcrumbs << crumb
    if state.breadcrumbs.length > MAX_BREADCRUMBS_PER_EVENT
      state.breadcrumbs.shift
      state.breadcrumbs_evicted += 1
    end
  end
  nil
rescue StandardError
  nil
end

.build_event(message, exception_type, frames, request: nil, fingerprint: nil, release: nil, environment: nil, user: nil, tags: nil, context: nil) ⇒ Object

Builds one wire event (string-keyed Hash). v2 fields are emitted only when NON-EMPTY, so an integration that never touches the v2 API keeps producing byte-identical v1 payloads.



319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/realuptime/errors.rb', line 319

def build_event(message, exception_type, frames, request: nil, fingerprint: nil, release: nil,
                environment: nil, user: nil, tags: nil, context: nil)
  state = @state
  breadcrumbs = nil
  breadcrumbs_dropped = 0
  scope_user = nil
  scope_tags = {}
  scope_context = {}
  device = nil
  if state
    state.lock.synchronize do
      breadcrumbs = state.breadcrumbs.dup unless state.breadcrumbs.empty?
      breadcrumbs_dropped = state.breadcrumbs_evicted
      scope_user = state.user.dup if state.user
      scope_tags = state.tags.dup
      scope_context = state.context.dup
      device = state.device.dup if state.device
    end
  end
  event = {
    "occurredAt" => iso_time(now_f),
    "message" => clip(message.to_s, MAX_MESSAGE_LENGTH),
    "exceptionType" => exception_type ? clip(exception_type, MAX_STRING_LENGTH) : nil,
    "release" => release || state&.release,
    "environment" => environment || state&.environment,
    "frames" => frames,
    "request" => normalize_request(request),
    "fingerprint" => fingerprint,
    "breadcrumbs" => breadcrumbs,
    "breadcrumbsDropped" => breadcrumbs_dropped
  }
  merged_user = scope_user
  if user.is_a?(Hash)
    merged_user = (scope_user || {}).dup
    USER_KEYS.each do |key|
      value = user[key] || user[key.to_sym]
      merged_user[key] = clip(value, MAX_STRING_LENGTH) if value.is_a?(String)
    end
  end
  event["user"] = merged_user if merged_user && !merged_user.empty?
  merged_tags = scope_tags.merge(bounded_map(tags, MAX_TAGS_PER_EVENT))
  event["tags"] = merged_tags unless merged_tags.empty?
  merged_context = scope_context.merge(bounded_map(context, MAX_CONTEXT_ENTRIES))
  event["context"] = merged_context unless merged_context.empty?
  event["device"] = device if device
  event
end

.capture_exception(exc, request: nil, fingerprint: nil, release: nil, environment: nil, user: nil, tags: nil, context: nil) ⇒ Object

Reports an exception. Never raises. user/tags/context are per-capture overrides merged OVER the sticky scope set by set_user/set_tag/ set_context.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/realuptime/errors.rb', line 135

def capture_exception(exc, request: nil, fingerprint: nil, release: nil, environment: nil,
                      user: nil, tags: nil, context: nil)
  state = @state
  return nil if state.nil?

  event = if exc.is_a?(Exception)
            message = exc.message.to_s
            message = exc.class.name.to_s if message.empty?
            build_event(message, exc.class.name.to_s, frames_from_exception(exc),
                        request: request, fingerprint: fingerprint, release: release,
                        environment: environment, user: user, tags: tags, context: context)
          else
            build_event(exc.to_s, nil, nil, request: request, fingerprint: fingerprint,
                                            release: release, environment: environment,
                                            user: user, tags: tags, context: context)
          end
  state.transport.enqueue(Scrub.scrub_event(event, state.allow_fields))
  nil
rescue StandardError => e
  safe_log(nil, "[realuptime-errors] capture_exception failed: #{e.class}: #{e.message}")
  nil
end

.capture_message(message, request: nil, fingerprint: nil, release: nil, environment: nil, user: nil, tags: nil, context: nil) ⇒ Object

Reports a plain message. Never raises.



159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/realuptime/errors.rb', line 159

def capture_message(message, request: nil, fingerprint: nil, release: nil, environment: nil,
                    user: nil, tags: nil, context: nil)
  state = @state
  return nil if state.nil?

  event = build_event(message.to_s, nil, nil, request: request, fingerprint: fingerprint,
                                              release: release, environment: environment,
                                              user: user, tags: tags, context: context)
  state.transport.enqueue(Scrub.scrub_event(event, state.allow_fields))
  nil
rescue StandardError
  nil
end

.closeObject

Test seam: drops state and stops the delivery thread.



302
303
304
305
306
307
308
309
# File 'lib/realuptime/errors.rb', line 302

def close
  state = @state
  @state = nil
  state&.transport&.close
  nil
rescue StandardError
  nil
end

.flushObject

Delivers anything buffered, synchronously. Never raises.



294
295
296
297
298
299
# File 'lib/realuptime/errors.rb', line 294

def flush
  @state&.transport&.flush
  nil
rescue StandardError
  nil
end

.frames_from_exception(exc) ⇒ Object

Wire frames from a Ruby exception, innermost first (Ruby's own backtrace order), capped at MAX_FRAMES_PER_EVENT. inApp is a best-effort guess: not under a gems directory, not the Ruby stdlib, not an internal:... frame.



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/realuptime/errors.rb', line 371

def frames_from_exception(exc)
  locations = exc.backtrace_locations
  frames = if locations
             locations.first(MAX_FRAMES_PER_EVENT).map do |loc|
               path = loc.absolute_path || loc.path || ""
               {
                 "file" => clip(path, MAX_STRING_LENGTH),
                 "function" => loc.label ? clip(loc.label, MAX_STRING_LENGTH) : nil,
                 "line" => loc.lineno,
                 "inApp" => in_app?(path)
               }
             end
           else
             (exc.backtrace || []).first(MAX_FRAMES_PER_EVENT).map { |line| parse_backtrace_line(line) }
           end
  frames.empty? ? nil : frames
rescue StandardError
  nil
end

.in_app?(path) ⇒ Boolean

Returns:

  • (Boolean)


391
392
393
394
395
396
397
398
399
# File 'lib/realuptime/errors.rb', line 391

def in_app?(path)
  return false if path.empty? || path.start_with?("<internal:")
  return false if path.include?("/gems/") || path.include?("/bundler/gems/")

  rubylib = RbConfig::CONFIG["rubylibdir"]
  return false if rubylib && !rubylib.empty? && path.start_with?(rubylib)

  true
end

.init(dsn: nil, release: nil, environment: nil, allow_fields: nil, capture_unhandled: true, send_device_info: true, background: true, opener: nil, now: nil, log: nil) ⇒ Object

Initializes the SDK. Safe to call twice (last call wins); a missing DSN logs once and stays inert. Never raises.

dsn                 the project's ingest URL (required)
release             e.g. a git SHA or version tag
environment         e.g. "production"
allow_fields        per-field opt-back-in: lowercased header names
                  ("x-request-id") and the dotted identity names
                  "user.email" / "user.username". Never a global
                  switch.
capture_unhandled   installs an at_exit hook that reports the
                  exception a process is dying from (default on)
send_device_info    runtime/platform facts about THIS process,
                  never hostname or IP (default on)
background          deliver on a background thread (default on);
                  false delivers inline on every capture

Keyword seams opener:/now:/log: exist for tests.



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/realuptime/errors.rb', line 106

def init(dsn: nil, release: nil, environment: nil, allow_fields: nil, capture_unhandled: true,
         send_device_info: true, background: true, opener: nil, now: nil, log: nil)
  unless dsn.is_a?(String) && !dsn.empty?
    safe_log(log, "[realuptime-errors] init called without a dsn; error reporting is disabled.")
    return nil
  end
  transport = Transport.new(dsn, opener: opener, now: now, log: log, background: background)
  @state = State.new(
    dsn: dsn,
    release: release,
    environment: environment,
    allow_fields: Array(allow_fields).map(&:to_s),
    transport: transport,
    device: send_device_info ? detect_device : nil
  )
  install_at_exit if capture_unhandled
  nil
rescue StandardError => e
  safe_log(log, "[realuptime-errors] init failed: #{e.class}: #{e.message}")
  nil
end

.initialized?Boolean

Returns:

  • (Boolean)


128
129
130
# File 'lib/realuptime/errors.rb', line 128

def initialized?
  !@state.nil?
end

.set_context(key, value = nil) ⇒ Object

One sticky custom-context entry, or a whole Hash merged in. set_context(nil) clears everything. Never raises.



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/realuptime/errors.rb', line 263

def set_context(key, value = nil)
  state = @state
  return nil if state.nil?

  if key.nil?
    state.lock.synchronize { state.context = {} }
    return nil
  end
  if key.is_a?(Hash)
    key.each { |name, entry| set_context(name, entry) }
    return nil
  end
  key = key.to_s
  return nil if key.empty?

  name = clip(key, MAX_CONTEXT_KEY_LENGTH)
  state.lock.synchronize do
    if value.nil?
      state.context.delete(name)
    elsif value.is_a?(String)
      unless !state.context.key?(name) && state.context.length >= MAX_CONTEXT_ENTRIES
        state.context[name] = clip(value, MAX_STRING_LENGTH)
      end
    end
  end
  nil
rescue StandardError
  nil
end

.set_tag(key, value) ⇒ Object

One sticky tag; nil removes it. Tags past MAX_TAGS_PER_EVENT are ignored rather than evicting an existing one. Never raises.



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/realuptime/errors.rb', line 230

def set_tag(key, value)
  state = @state
  return nil if state.nil?

  key = key.to_s
  return nil if key.empty?

  name = clip(key, MAX_CONTEXT_KEY_LENGTH)
  state.lock.synchronize do
    if value.nil?
      state.tags.delete(name)
    elsif value.is_a?(String)
      unless !state.tags.key?(name) && state.tags.length >= MAX_TAGS_PER_EVENT
        state.tags[name] = clip(value, MAX_STRING_LENGTH)
      end
    end
  end
  nil
rescue StandardError
  nil
end

.set_tags(tags) ⇒ Object



252
253
254
255
256
257
258
259
# File 'lib/realuptime/errors.rb', line 252

def set_tags(tags)
  return nil unless tags.is_a?(Hash)

  tags.each { |name, value| set_tag(name, value) }
  nil
rescue StandardError
  nil
end

.set_user(user) ⇒ Object

Sticky identity applied to every subsequent event; nil clears it. Only id / email / username are carried. email and username are "[scrubbed]" before serialization unless the matching allow_fields entry is set. Never raises.



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/realuptime/errors.rb', line 209

def set_user(user)
  state = @state
  return nil if state.nil?

  next_user = nil
  if user.is_a?(Hash)
    next_user = {}
    USER_KEYS.each do |key|
      value = user[key] || user[key.to_sym]
      next_user[key] = clip(value, MAX_STRING_LENGTH) if value.is_a?(String)
    end
    next_user = nil if next_user.empty?
  end
  state.lock.synchronize { state.user = next_user }
  nil
rescue StandardError
  nil
end

.transportObject

Exposed for tests and adapters.



312
313
314
# File 'lib/realuptime/errors.rb', line 312

def transport
  @state&.transport
end