Module: Foam::Otel::BeforeSend

Defined in:
lib/foam/otel/before_send.rb

Overview

The customer's before_send: hook(s) (1.8.0), run at the EXPORT BOUNDARY — after batching, immediately BEFORE foam's redaction pass and the OTLP serialization, so a record a hook drops is never serialized and never leaves the process. Why not a SpanProcessor: the same Ruby SDK constraint that forced redaction to the exporter boundary — a span freezes its attributes at finish BEFORE any on_finish processor runs (GOTCHAS F1), so a processor could neither mutate nor reliably drop. The mutable SpanData / LogRecordData Structs the batch processors hand the exporter are the first (and last) point where per-record transform

  • drop is possible before the wire.

Composition order (pinned, pipelines.rb):

BatchProcessor -> BeforeSend::*Exporter -> Redacting*Exporter -> OTLP

The hooks run FIRST so the credential floor, the value-pattern secret layer and the customer's key lists still apply to whatever a hook returns — before_send can never widen what ships past redaction (an attribute a hook ADDS is masked exactly like one an instrumentation set).

Contract per record (Sentry beforeSend semantics — the spec's named fail-closed precedent, BASE_PACKAGE_SPEC D6):

* return the record (mutating it in place is fine) → next hook /
export;
* return nil → record DROPPED (the intentional filter mechanism —
success, not a failure);
* raise, or return a foreign object → record DROPPED, loudly, and
the batch reports FAILURE (fail-closed: a hook fault must never
ship a record the customer may have meant to scrub, and must never
raise into the export thread — rules 9/14/15). SystemStackError is
rescued explicitly (not a StandardError): a hook recursing on a
poisoned payload kills neither the batch thread nor the caller.

The record reaches the first hook as a shallow dup with an UNFROZEN attributes copy (span attributes freeze at finish — GOTCHAS F1), so record.attributes["k"] = v just works; the dup also keeps the customer's mutations off the struct any other consumer might hold.

Scope: spans and logs (the two record-shaped signals). Metric data is aggregated state, not records — deliberately NOT routed through before_send (documented in the README).

Defined Under Namespace

Classes: LogRecordExporter, SpanExporter

Constant Summary collapse

ERROR =

Internal sentinel distinguishing a FAULTED drop (counts toward batch FAILURE) from an intentional nil drop. A hook can never return it legitimately — any non-record return is itself classified a fault.

Object.new.freeze

Class Method Summary collapse

Class Method Details

.collection_size(record, list_getter) ⇒ Object



193
194
195
196
197
198
199
200
# File 'lib/foam/otel/before_send.rb', line 193

def collection_size(record, list_getter)
  return 0 unless record.respond_to?(list_getter)

  list = record.public_send(list_getter)
  list.respond_to?(:size) ? list.size.to_i : 0
rescue StandardError
  0
end

.identity_of(record) ⇒ Object

resource / instrumentation_scope are process-global IDENTITY objects shared by every record the tracer/logger emits — and Ruby's InstrumentationScope is a plain MUTABLE Struct. A hook that renames the scope would corrupt the identity of EVERY subsequent span/log from that instrument (proven live in the 2026-07-29 retro-probe), and a wholesale record.resource = replacement would ship hook-authored resource attributes RAW — redaction never masks resource, so that path could widen what ships. Both are closed the way the Go port does it: identity always comes from the ORIGINAL record — captured here, force-restored onto the survivor after the pipeline (hook edits to identity are discarded; the encoder's group-by-identity stays intact). Documented in the README: resource/instrumentation_scope are informational, not hook-writable.



137
138
139
140
141
142
# File 'lib/foam/otel/before_send.rb', line 137

def identity_of(record)
  {
    resource: (record.resource if record.respond_to?(:resource)),
    scope: (record.instrumentation_scope if record.respond_to?(:instrumentation_scope)),
  }
end

.restore_counters!(record, counters) ⇒ Object

Re-normalize the survivor's total_recorded_* bookkeeping after the hooks ran: total = what the record NOW carries + what the SDK had already dropped. Without this, a hook-ADDED attribute makes the OTLP encoder's total - size subtraction negative (protobuf uint32 → RangeError → the encoder returns nil → the ENTIRE batch is dropped, silently, forever), and a hand-built replacement record with nil totals raises NoMethodError at the same line. Also keeps a hook DELETE honest (the wire no longer claims an SDK-limit drop).



210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/foam/otel/before_send.rb', line 210

def restore_counters!(record, counters)
  if record.respond_to?(:total_recorded_attributes=)
    record.total_recorded_attributes = collection_size(record, :attributes) + counters[:attributes]
  end
  if record.respond_to?(:total_recorded_events=)
    record.total_recorded_events = collection_size(record, :events) + counters[:events]
  end
  if record.respond_to?(:total_recorded_links=)
    record.total_recorded_links = collection_size(record, :links) + counters[:links]
  end
  record
end

.restore_identity!(record, identity) ⇒ Object



144
145
146
147
148
149
150
# File 'lib/foam/otel/before_send.rb', line 144

def restore_identity!(record, identity)
  record.resource = identity[:resource] if record.respond_to?(:resource=)
  if record.respond_to?(:instrumentation_scope=)
    record.instrumentation_scope = identity[:scope]
  end
  record
end

.run(hooks, record, signal, faults) ⇒ Object

Run the hook pipeline over one record: each hook receives the previous hook's return. Returns the surviving record, nil for an intentional drop, or ERROR for a faulted one. Fault DETAIL is appended to faults (error class / return class only — never a message, which can embed record values) so the exporter can warn ONCE per batch instead of once per record (the redacting exporters' aggregation convention; an unthrottled per-record warn is a stderr flood an adversary-shaped record stream controls — rule 15's "loud but never a flood" posture).



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/foam/otel/before_send.rb', line 64

def run(hooks, record, signal, faults)
  identity = identity_of(record)
  current, counters = thaw(record)
  hooks.each do |hook|
    result = hook.call(current)
    return nil if result.nil?

    unless result.is_a?(record.class)
      faults << "hook returned #{result.class}"
      return ERROR
    end
    current = result
  end
  restore_counters!(current, counters)
  restore_identity!(current, identity)
  current
rescue StandardError, SystemStackError => e
  faults << e.class.name
  # Full detail (message included) only under diagnostics: true —
  # e.message can carry attribute values.
  Diagnostics.info("before_send fault detail (#{signal}): #{e.class}: #{e.message}")
  ERROR
end

.sdk_drop_counters(record) ⇒ Object

How many attributes/events/links the SDK itself dropped (limits) BEFORE the hooks ran — nil-safe, clamped at zero.



177
178
179
180
181
182
183
# File 'lib/foam/otel/before_send.rb', line 177

def sdk_drop_counters(record)
  {
    attributes: sdk_dropped(record, :total_recorded_attributes, :attributes),
    events: sdk_dropped(record, :total_recorded_events, :events),
    links: sdk_dropped(record, :total_recorded_links, :links),
  }
end

.sdk_dropped(record, total_getter, list_getter) ⇒ Object



185
186
187
188
189
190
191
# File 'lib/foam/otel/before_send.rb', line 185

def sdk_dropped(record, total_getter, list_getter)
  return 0 unless record.respond_to?(total_getter)

  total = record.public_send(total_getter).to_i
  size = collection_size(record, list_getter)
  total > size ? total - size : 0
end

.thaw(record) ⇒ Object

A dup the hook can safely mutate IN PLACE: the struct and the attributes hash are copied, and so are the mutable attribute VALUES (strings, and strings inside string arrays — the OTel attribute types) plus a structured log body — so record.attributes["k"] = v, attributes["k"].gsub!(...) and record.body["k"] = v never bleed into the live span/record the customer still holds or the masked view a tenant processor buffered (pipelines.rb), and never hit a frozen value. Events/links/status are NOT deep-copied: replace them wholesale (record.events = [...]), never mutate them in place — documented in the README.

Also returns the record's SDK-drop counters, captured BEFORE the hooks run: the OTLP exporters encode dropped_attributes_count as total_recorded_attributes - attributes.size (a protobuf uint32), so a hook that ADDS an attribute without the total being fixed up afterwards makes the count NEGATIVE and the encoder drops the WHOLE export request — every healthy record in the batch, silently (the same trap session_stitching.rb documents and guards for foam's own added attributes). restore_counters! below re-normalizes the survivor from these.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/foam/otel/before_send.rb', line 108

def thaw(record)
  copy = record.dup
  if copy.respond_to?(:attributes) && copy.respond_to?(:attributes=)
    attributes = copy.attributes
    copy.attributes = attributes.nil? ? {} : attributes.transform_values { |v| thaw_value(v) }
  end
  copy.body = thaw_body(copy.body) if copy.respond_to?(:body) && copy.respond_to?(:body=)
  # The scope handed to the hook is a COPY: InstrumentationScope is a
  # mutable Struct, and an in-place `scope.name =` on the shared
  # object would corrupt process-global identity (see identity_of).
  if copy.respond_to?(:instrumentation_scope) && copy.respond_to?(:instrumentation_scope=)
    copy.instrumentation_scope = copy.instrumentation_scope&.dup
  end
  [copy, sdk_drop_counters(record)]
end

.thaw_body(body, depth = 0) ⇒ Object

Bounded deep copy of a log body (String / Hash / Array / scalar) so in-place body mutation is safe too; the depth guard mirrors the redaction engine's (rule 9 — an adversarial structure never drives a stack overflow).



164
165
166
167
168
169
170
171
172
173
# File 'lib/foam/otel/before_send.rb', line 164

def thaw_body(body, depth = 0)
  return body if depth > MAX_REDACT_DEPTH

  case body
  when String then body.dup
  when Hash then body.each_with_object({}) { |(k, v), out| out[k] = thaw_body(v, depth + 1) }
  when Array then body.map { |v| thaw_body(v, depth + 1) }
  else body
  end
end

.thaw_value(value) ⇒ Object



152
153
154
155
156
157
158
# File 'lib/foam/otel/before_send.rb', line 152

def thaw_value(value)
  case value
  when String then value.dup
  when Array then value.map { |e| e.is_a?(String) ? e.dup : e }
  else value
  end
end