Module: Alplus::Envelope

Defined in:
lib/alplus/envelope.rb

Overview

Builds the POST /e/errors wire envelope. Mirrors packages/sdk/src/core/observe/{client,envelope}.ts (the TypeScript SDK) and the Elixir SDK (#13): {header: {key, sdk, sent_at}, items: [...]} with one item per request. Kept additive-only per docs/BUILD.md §5 — every field here already exists in the accepted server shape.

Per-field caps mirror the JS SDK's own write-boundary caps (envelope.ts's MAX_* constants) so an oversized field is trimmed HERE, per field, and the event still sends — the same trade-off the JS SDK makes. Transport::MAX_ENVELOPE_BYTES's whole-envelope check stays a last-resort safety net for the case these per-field caps still don't fit (should be unreachable in practice, same as the JS SDK's own comment on its equivalent guard).

Constant Summary collapse

MAX_ENVELOPE_BYTES =
1_048_576
MAX_MESSAGE_CHARS =
4_096
MAX_EXCEPTION_VALUE_CHARS =
4_096
MAX_STACK_TRACE_CHARS =
16_384
MAX_CONTEXT_CHARS =
8_192
MAX_TAGS_CHARS =
4_096
SERVER_MAX_BREADCRUMBS =
100
MAX_BREADCRUMB_MESSAGE_CHARS =
2_048
MAX_BREADCRUMB_CATEGORY_CHARS =
128
MAX_USER_FIELD_CHARS =

The server's errorItemSchema.user is .strict() with only id/ email (packages/schemas/src/observe/error-envelope.ts) — an unrecognized key rejects the whole item, so cap_user below picks only these two rather than forwarding an arbitrary hash. The JS SDK passes user through uncapped (no MAX_USER_* there); this cap is an SDK-side addition for the same defensive-write-boundary discipline every other free-text field already gets here.

256
MAX_FINGERPRINT_ENTRIES =

Mirrors the server's errorItemSchema.fingerprint (z.array(z.string().max(256)).min(1).max(16), issue #17).

16
MAX_FINGERPRINT_CHARS =
256
MAX_CAUSE_DEPTH =

The server accepts an exception cause chain up to depth 5 counting the top-level exception (Alplus.Observe.ErrorEnvelope); walk at most 4 causes so a cyclic or absurd chain can never build an over-deep item.

4

Class Method Summary collapse

Class Method Details

.base_item(id:, type:, level:, config:, mechanism:, context:, contexts:, tags:, breadcrumbs:, user:, fingerprint:) ⇒ Object

context: is the free-text convenience that folds into contexts.extra (and, per capture, always REPLACES whatever ambient extra context the caller's scope carried — it does not deep-merge with it); contexts: is the arbitrary named-map form. Both fold into one contexts wire key, matching the JS SDK and fixing this SDK's previous context-only shape (issue #17).



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/alplus/envelope.rb', line 123

def base_item(id:, type:, level:, config:, mechanism:, context:, contexts:, tags:, breadcrumbs:, user:, fingerprint:)
  item = {
    id: id,
    type: type,
    timestamp: Time.now.utc.iso8601,
    level: level.to_s,
    release: config.release,
    environment: config.environment,
    mechanism: mechanism
  }
  named_contexts = contexts ? contexts.dup : {}
  named_contexts[:extra] = context if context && !context.empty?
  item[:contexts] = cap_context(named_contexts, MAX_CONTEXT_CHARS) unless named_contexts.empty?
  capped_tags = cap_tags(tags, MAX_TAGS_CHARS)
  item[:tags] = capped_tags if capped_tags
  capped_crumbs = cap_breadcrumbs(breadcrumbs, SERVER_MAX_BREADCRUMBS)
  item[:breadcrumbs] = capped_crumbs if capped_crumbs && !capped_crumbs.empty?
  capped_user = cap_user(user)
  item[:user] = capped_user if capped_user
  capped_fingerprint = cap_fingerprint(fingerprint)
  item[:fingerprint] = capped_fingerprint if capped_fingerprint
  item.compact
end

.build_cause(exception, config, depth) ⇒ Object

Walks Exception#cause into the wire cause chain (Honeybadger/ Sentry-style "caused by"). Each cause carries its own type, message, and backtrace. Depth-bounded; a cause loop (possible via handwritten cause overrides) terminates at the bound instead of hanging.



100
101
102
103
104
105
106
107
108
109
110
# File 'lib/alplus/envelope.rb', line 100

def build_cause(exception, config, depth)
  return nil if exception.nil? || depth <= 0

  built = { type: exception.class.name, value: cap_text(exception.message.to_s, MAX_EXCEPTION_VALUE_CHARS) }
  frames = Stack.frames_for(exception, app_dirs: config.app_dirs, context_lines: config.context_lines.to_i)
  capped_frames = cap_frames(frames, MAX_STACK_TRACE_CHARS)
  built[:stacktrace] = { frames: capped_frames } unless capped_frames.empty?
  nested = build_cause(exception.cause, config, depth - 1)
  built[:cause] = nested if nested
  built
end

.cap_breadcrumbs(breadcrumbs, max_count) ⇒ Object

Caps breadcrumb count to the server's own ceiling (keeping the most recent ones) and per-breadcrumb message/category length.



223
224
225
226
227
228
229
230
231
232
# File 'lib/alplus/envelope.rb', line 223

def cap_breadcrumbs(breadcrumbs, max_count)
  return nil if breadcrumbs.nil?

  breadcrumbs.last(max_count).map do |crumb|
    crumb = crumb.dup
    crumb[:message] = cap_text(crumb[:message], MAX_BREADCRUMB_MESSAGE_CHARS) if crumb[:message]
    crumb[:category] = cap_text(crumb[:category], MAX_BREADCRUMB_CATEGORY_CHARS) if crumb[:category]
    crumb
  end
end

.cap_context(value, max_chars) ⇒ Object

Caps a JSON-ish hash by its serialized size. A value whose serialization exceeds max_chars is REPLACED by a small truncation marker rather than cut mid-string, mirroring the JS SDK's capContext — a partial JSON string is unparseable, which is worse than a shorter one.



162
163
164
165
166
167
# File 'lib/alplus/envelope.rb', line 162

def cap_context(value, max_chars)
  serialized = JSON.generate(value)
  return value if serialized.bytesize <= max_chars

  { _truncated: true, _original_chars: serialized.bytesize }
end

.cap_fingerprint(fingerprint) ⇒ Object

Caps the custom fingerprint override to the server's own bounds: at most MAX_FINGERPRINT_ENTRIES entries, each at most MAX_FINGERPRINT_CHARS characters. Returns nil for a nil/empty input so the caller can omit the wire key.



238
239
240
241
242
# File 'lib/alplus/envelope.rb', line 238

def cap_fingerprint(fingerprint)
  return nil if fingerprint.nil? || fingerprint.empty?

  fingerprint.first(MAX_FINGERPRINT_ENTRIES).map { |part| cap_text(part.to_s, MAX_FINGERPRINT_CHARS) }
end

.cap_frames(frames, max_chars) ⇒ Object

Drops trailing frames until the serialized array fits max_chars, mirroring the JS SDK's capFrames/the server's capFramesToBudget.

Binary-searches the cut point (O(log n) JSON.generate calls) instead of popping one frame and re-serializing the whole array per pop (O(n) calls, each itself O(n)) — a deep recursive backtrace can carry thousands of frames, where the naive approach is noticeably slow.



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/alplus/envelope.rb', line 188

def cap_frames(frames, max_chars)
  return frames if frames.empty? || JSON.generate(frames).bytesize <= max_chars

  low = 0
  high = frames.length - 1
  while low < high
    mid = (low + high + 1) / 2
    if JSON.generate(frames.first(mid)).bytesize <= max_chars
      low = mid
    else
      high = mid - 1
    end
  end
  frames.first(low)
end

.cap_tags(tags, max_chars) ⇒ Object

Drops the tags object entirely (with a debug-log warning) rather than send a truncated Hash that would no longer round-trip as one — same trade-off the JS SDK's capTags makes. Returns nil for a nil/ empty input so the caller can omit the key.



173
174
175
176
177
178
# File 'lib/alplus/envelope.rb', line 173

def cap_tags(tags, max_chars)
  return nil if tags.nil? || tags.empty?
  return tags if JSON.generate(tags).bytesize <= max_chars

  nil
end

.cap_text(value, max_length) ⇒ Object

Truncates a string to at most max_length characters. Passes non-strings through #to_s first; callers already do this, kept defensive here since a truncated field is safer than a raise.



150
151
152
153
154
155
# File 'lib/alplus/envelope.rb', line 150

def cap_text(value, max_length)
  value = value.to_s
  return value if value.length <= max_length

  value[0, max_length]
end

.cap_user(user) ⇒ Object

Builds the wire user object: only id/email (accepts either symbol or string keys from the caller), each length-capped, matching the server's .strict() errorItemSchema.user — any other key would get the whole item rejected, so unrecognized keys are dropped rather than forwarded. Returns nil for a nil/empty input, or if neither recognized key is present, so the caller can omit the wire key.



210
211
212
213
214
215
216
217
218
219
# File 'lib/alplus/envelope.rb', line 210

def cap_user(user)
  return nil if user.nil? || user.empty?

  id = user[:id] || user["id"]
  email = user[:email] || user["email"]
  built = {}
  built[:id] = cap_text(id, MAX_USER_FIELD_CHARS) if id
  built[:email] = cap_text(email, MAX_USER_FIELD_CHARS) if email
  built.empty? ? nil : built
end

.exception_item(id:, exception:, config:, level: "error", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: nil, mechanism: "generic", fingerprint: nil, frames: nil) ⇒ Object

frames: overrides the normal Stack.frames_for(exception, ...) capture with an already-built wire frame array, for callers that already have wire-shaped frames (mirrors the Elixir SDK's Envelope.build_frame(%{} = wire_frame, _) passthrough). Real capture callers never pass this; it exists so the golden-envelope contract spec (issue #18) can call this REAL function with the golden's literal, cross-language-reproducible frames instead of a real backtrace tied to one language's stack-trace format.



84
85
86
87
88
89
90
91
92
93
94
# File 'lib/alplus/envelope.rb', line 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)
  frames ||= Stack.frames_for(exception, app_dirs: config.app_dirs, context_lines: config.context_lines.to_i)
  exc = { type: exception.class.name, value: cap_text(exception.message.to_s, MAX_EXCEPTION_VALUE_CHARS) }
  capped_frames = cap_frames(frames, MAX_STACK_TRACE_CHARS)
  exc[:stacktrace] = { frames: capped_frames } unless capped_frames.empty?
  cause = build_cause(exception.cause, config, MAX_CAUSE_DEPTH)
  exc[:cause] = cause if cause

  base_item(id: id, type: "exception", level: level, config: config, mechanism: mechanism, context: context, contexts: contexts, tags: tags, breadcrumbs: breadcrumbs, user: user, fingerprint: fingerprint)
    .merge(exception: exc)
end

.message_item(id:, message:, config:, level: "info", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: nil, mechanism: "generic", fingerprint: nil) ⇒ Object



112
113
114
115
# File 'lib/alplus/envelope.rb', line 112

def message_item(id:, message:, config:, level: "info", context: nil, contexts: nil, tags: nil, breadcrumbs: nil, user: nil, mechanism: "generic", fingerprint: nil)
  base_item(id: id, type: "message", level: level, config: config, mechanism: mechanism, context: context, contexts: contexts, tags: tags, breadcrumbs: breadcrumbs, user: user, fingerprint: fingerprint)
    .merge(message: cap_text(message.to_s, MAX_MESSAGE_CHARS))
end

.session_item(session:, config:) ⇒ Object

Builds a POST /e/sessions wire item (issue #12) from an Alplus::Session. Carries no PII: session.id is opaque and used server-side only for in-window ingest dedup, then discarded (never stored raw) — matching Alplus.Observe.SessionEnvelope, the server parser.



65
66
67
68
69
70
71
72
73
74
# File 'lib/alplus/envelope.rb', line 65

def session_item(session:, config:)
  {
    id: session.id,
    status: session.status.to_s,
    started_at: session.started_at.iso8601,
    duration_ms: ((Time.now.utc - session.started_at) * 1000).round,
    release: config.release,
    environment: config.environment
  }.compact
end

.wrap(config:, item:) ⇒ Object



49
50
51
52
53
54
55
56
57
58
# File 'lib/alplus/envelope.rb', line 49

def wrap(config:, item:)
  {
    header: {
      key: config.key,
      sdk: { name: Alplus::SDK_NAME, version: Alplus::VERSION, platform: "ruby" },
      sent_at: Time.now.utc.iso8601
    },
    items: [item]
  }
end