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
- .collection_size(record, list_getter) ⇒ Object
-
.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.
-
.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.
- .restore_identity!(record, identity) ⇒ Object
-
.run(hooks, record, signal, faults) ⇒ Object
Run the hook pipeline over one record: each hook receives the previous hook's return.
-
.sdk_drop_counters(record) ⇒ Object
How many attributes/events/links the SDK itself dropped (limits) BEFORE the hooks ran — nil-safe, clamped at zero.
- .sdk_dropped(record, total_getter, list_getter) ⇒ Object
-
.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!(...)andrecord.body["k"] = vnever 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. -
.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).
- .thaw_value(value) ⇒ Object
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.}") 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 |