Class: Foam::Otel::RedactingSpanExporter

Inherits:
Object
  • Object
show all
Defined in:
lib/foam/otel/redacting_exporter.rb

Overview

The central enforcement point: the ALWAYS-ON CREDENTIAL FLOOR (constants.rb / redaction.rb — fleet ruling 2026-07-26) plus the customer's opt-in key pass (redact_keys / redact_pii_keys) run HERE, at the exporter boundary, on door 1 AND door 2. Why not a SpanProcessor: the Ruby SDK freezes a span's attributes at finish, BEFORE any on_finish processor runs (opentelemetry-sdk span.rb — GOTCHAS entry), so no processor can mask them. SpanData / LogRecordData / Event are mutable Structs the exporter can rebuild, so masking happens on the copy foam serializes. The pass runs UNCONDITIONALLY — the floor has no off switch; values under unmatched names ship RAW. (Foam's own HELPERS additionally apply the same pass at capture — api.rb stringify / set_attribute — and the tenant seam hands additional_* processors a masked VIEW — pipelines.rb — so tenants only ever see masked data.)

These wrap (never subclass) the real OTLP exporter by composition, so they are robust across exporter versions — they only rely on the public export/force_flush/shutdown contract the batch processors call.

FAIL CLOSED: once a key pass has begun, Redaction.mask_* return NIL on a hard failure — a nil entry is DROPPED with a loud [foam] warning (never a half-masked record) and the batch reports FAILURE. If masking somehow raises past its own guards, the whole batch is dropped. SystemStackError is rescued explicitly (it is not a StandardError): a poisoned payload must kill neither the batch thread nor the caller (rule 9 / rule 15).

The F1 tenant divergence is CLOSED (1.3.0, credential-floor design §5.4): attributes still freeze at finish, so they cannot be masked IN PLACE before a tenant additional_* processor — instead the guarded tenant wrappers (pipelines.rb) hand tenants a read-only MASKED VIEW built through the same Redaction pass these exporters apply, third-party instrumentation attributes included.

Instance Method Summary collapse

Constructor Details

#initialize(inner, config) ⇒ RedactingSpanExporter

Returns a new instance of RedactingSpanExporter.



40
41
42
43
# File 'lib/foam/otel/redacting_exporter.rb', line 40

def initialize(inner, config)
  @inner = inner
  @config = config
end

Instance Method Details

#export(span_data, timeout: nil) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/foam/otel/redacting_exporter.rb', line 45

def export(span_data, timeout: nil)
  batch = span_data.to_a
  masked = batch.filter_map { |sd| Redaction.mask_span_data(sd, @config) }
  dropped = batch.length - masked.length
  if dropped.positive?
    Diagnostics.warn("redaction failed for #{dropped} span(s) — dropped rather than exported " \
                     "unmasked (rule 14 fail-closed)")
    @inner.export(masked, timeout: timeout) unless masked.empty?
    return OpenTelemetry::SDK::Trace::Export::FAILURE
  end
  @inner.export(masked, timeout: timeout)
rescue StandardError, SystemStackError
  # Fail closed: if masking somehow raises past its own guards, drop the
  # batch rather than export unmasked. Never break the caller — and never
  # SILENTLY (rule 15): the rarest, most alarming drop must still be loud.
  Diagnostics.warn("span redaction raised past its guards — batch dropped (fail-closed, rule 14/15)")
  OpenTelemetry::SDK::Trace::Export::FAILURE
end

#force_flush(timeout: nil) ⇒ Object



64
# File 'lib/foam/otel/redacting_exporter.rb', line 64

def force_flush(timeout: nil) = @inner.force_flush(timeout: timeout)

#shutdown(timeout: nil) ⇒ Object



65
# File 'lib/foam/otel/redacting_exporter.rb', line 65

def shutdown(timeout: nil) = @inner.shutdown(timeout: timeout)