foam-otel 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48166d05b120dd8bdca15a9b45db5b4c16a6574fcb6890f4caff2c17c2ae9add
4
- data.tar.gz: 2dcd4b3b146a0221c231ba5efc057085418b74e21b24d8441396d47cfc0a8288
3
+ metadata.gz: 6ef766d3509a7ee5daa25c7f53dce352b9c67c8ed22283099c72f3d6f0b8e957
4
+ data.tar.gz: caca5adca96f4e3dab3d0610ce54000858cb231cbf167e5bb6dd0c6f1dcd6a9b
5
5
  SHA512:
6
- metadata.gz: 4e92c7af8cc5cf7cc177b792f87a8107b918035efdce080e2a974750180ebbb9aac4a5460cb08372f3ded2724d136804bcb8f0574ee4685ebbdd94fde7afb3e8
7
- data.tar.gz: ddd7b6e431034663ec94dacd8b7b051ee63bfecdbeb2a38d383c58531854ba0580f34c9a04ed56473b654b89155fc9a3b7ce140b073ba346d90a5a801b9c6d4b
6
+ metadata.gz: 55da8fd55a07d287d81fddf5a65eeb8b7e0f743d1ec8b034362972f5c02a15a84bab18119aacf9e881b4deefc61d6958e062990fe3b205f66f2a88fd11ba514b
7
+ data.tar.gz: 70e46934c6250308cb8d15c90a160eeab5ce916b294cfc7395e46fc07e0d8d2027ded17f3f21cdcf3125c35773fb899324eb0374c6326c04d73a2908e0e1024d
data/GOTCHAS.md CHANGED
@@ -808,6 +808,67 @@ exfiltratable — the value-pattern secret layer is the required second control
808
808
 
809
809
  ---
810
810
 
811
+ ## F16: before_send hooks run inside the export lock, and record bookkeeping must be re-normalized behind them
812
+
813
+ - **Trap**: Three traps share the seam. (1) The batch processors call
814
+ `exporter.export` inside `@export_mutex.synchronize` with no lock timeout —
815
+ a customer `before_send` hook that blocks (DB lookup, un-timed HTTP, sleep)
816
+ wedges the worker thread AND any concurrent `force_flush`/`shutdown`
817
+ acquiring the same mutex: the process can hang at exit until SIGKILL.
818
+ (2) The OTLP exporters encode `dropped_attributes_count` as
819
+ `total_recorded_attributes - attributes.size` into a protobuf **uint32** —
820
+ a hook that ADDS an attribute without the total being fixed up makes the
821
+ subtraction negative, protobuf raises `RangeError`, `encode` rescues to
822
+ nil, and the WHOLE export request — every healthy record batched beside
823
+ the touched one — is dropped, silently, forever (the same trap
824
+ session_stitching already documents for foam's own stamped attributes; a
825
+ hand-built replacement record with nil totals is the NoMethodError variant
826
+ of the same encode failure). (3) A hook fault marks the whole batch
827
+ FAILURE (redacting-exporter parity), and `force_flush` aborts its drain
828
+ loop on the first non-SUCCESS batch — so a hook that faults
829
+ DETERMINISTICALLY on some record shape makes the at-exit flush abandon
830
+ everything queued behind the first faulted batch.
831
+ - **Sources**:
832
+ - Installed source: `OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor`
833
+ `#export_batch` inside `@export_mutex.synchronize`
834
+ (opentelemetry-sdk `trace/export/batch_span_processor.rb:189`), same shape
835
+ in `logs/export/batch_log_record_processor.rb:186`; `force_flush`'s
836
+ `return result_code unless result_code == SUCCESS` drain-abort
837
+ (`batch_span_processor.rb:113-115`).
838
+ - Installed source: `dropped_attributes_count: span_data.total_recorded_attributes - span_data.attributes&.size.to_i`
839
+ (opentelemetry-exporter-otlp `exporter.rb:343`; logs twin
840
+ `logs_exporter.rb:311`); `encode` rescues `StandardError` → nil →
841
+ `send_bytes(nil)` returns FAILURE for the whole request.
842
+ - In-repo precedent: `lib/foam/otel/session_stitching.rb` bumps
843
+ `@total_recorded_attributes` when foam adds its own attribute, guarding
844
+ against exactly trap (2).
845
+ - **Decision & why**: Hooks stay ON the export thread (any queue/thread
846
+ indirection would break the drop-before-serialize guarantee and add an
847
+ unbounded buffer); foam re-normalizes `total_recorded_*` on every
848
+ surviving record after the pipeline (`BeforeSend.restore_counters!` —
849
+ size + SDK-dropped delta captured pre-hook, nil-safe), hands hooks COPIED
850
+ attribute values and log bodies so in-place mutation cannot bleed into
851
+ the live record or a tenant's buffered view, and keeps FAILURE accounting
852
+ for faulted records (parity with the redacting exporters) while warning
853
+ ONCE per batch with error classes only. The blocking-hook risk is
854
+ accepted and documented (README "The `before_send` hook") — it is the
855
+ customer's own code on their own pipeline, the same trust grade as a
856
+ tenant processor, and rule 49's DoS bounds govern foam's code, not the
857
+ customer's.
858
+ - **Mitigation**: README pins the contract: non-blocking hooks only; stamp
859
+ request/job-thread context onto attributes at capture; replace (never
860
+ mutate) events/links/status; deterministic-fault hooks are a customer bug
861
+ surfaced by the aggregated per-batch `[foam]` warning + batch FAILURE.
862
+ Boot-time arity validation rejects hooks that cannot take one positional
863
+ argument (the Sentry two-arg port) so the fault-every-record shape is
864
+ caught on the engineer's machine (rule 10).
865
+ - **Test**: `spec/before_send_spec.rb` ("re-normalizes total_recorded",
866
+ "REAL OTLP encode", "aggregates hook faults", "arity", "in-place value
867
+ mutation never bleeds"); `spec/before_send_champ_scenario_spec.rb` (the
868
+ export-thread / stamped-context proof).
869
+
870
+ ---
871
+
811
872
  ## General gotchas (applicable to Ruby)
812
873
 
813
874
  - **G1 — init after target import / pre-init no-op**: the API's proxy providers
data/README.md CHANGED
@@ -331,6 +331,7 @@ not captured — inbound only.
331
331
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
332
332
  | `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
333
333
  | `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
334
+ | `before_send:` | Proc/Array | no | `[]` | Export hook (1.8.0): one callable — or an Array run as a pipeline — called with every **span and log record** at the export boundary, after batching and BEFORE foam's redaction pass and serialization, so nothing a hook drops ever leaves the process. Return the record (or a same-type replacement) to export it — in-place mutation supported; return `nil` to drop it; a raise or foreign return drops **that record** loudly, fail-closed, never a raise into your threads. Runs BEFORE redaction — a hook can never widen what ships. Spans and logs only; door 1 only. A non-callable (or a one-arg-incompatible lambda) raises at boot. Process-global. Hooks run on the export thread — keep them non-blocking. Full contract: "The `before_send` hook" below. |
334
335
  | `capture_payloads:` | Symbol/String | no | `:off` | Inbound HTTP **body** capture on the rack server span (see "Payload (body) capture"). `:off` = zero teeing, zero middleware (the default); `:errors` = tee per request, attach only on exception/5xx; `:always` = attach on every request. Equivalent strings accepted; anything else raises at boot. Overridden in both directions by the `FOAM_CAPTURE_PAYLOADS` env clamp (env table below). Textual payloads only, 8192-char cap + `…[truncated]`, true `.size` attributes; sizes-only for binary/compressed. |
335
336
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
336
337
  | `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
@@ -388,6 +389,24 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
388
389
  ignored_outbound_hosts: ["ingest.eval-tool.example"]) # the tenant loop guard — required
389
390
  ```
390
391
 
392
+ ```ruby
393
+ # The before_send export hook (1.8.0): transform or drop spans/logs right
394
+ # before they are exported to foam — after batching, BEFORE redaction and
395
+ # serialization (a dropped record never leaves the process). Return the
396
+ # record (mutate in place freely) or nil to drop; a raising hook drops ONLY
397
+ # its record, loudly, and never raises into your threads (fail-closed).
398
+ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
399
+ before_send: lambda { |record|
400
+ next nil if record.respond_to?(:name) && record.name == "GET /healthz" # drop noise
401
+
402
+ record.attributes.delete("internal.debug.blob") # trim before it ships
403
+ record
404
+ })
405
+ # Or an Array pipeline — hooks run in order, each receiving the previous
406
+ # hook's return; the first nil drops the record and short-circuits:
407
+ # Foam::Otel.init(..., before_send: [DropHealthchecks, TrimDebugAttrs, StampTeam])
408
+ ```
409
+
391
410
  ```ruby
392
411
  # Verbose self-reporting while wiring foam up (warnings are loud regardless):
393
412
  Foam::Otel.init(name: "checkout-api", environment: "development", enabled: true,
@@ -616,6 +635,79 @@ processor = Foam::Otel.create_ingest_span_processor(
616
635
 
617
636
  ---
618
637
 
638
+ ## The `before_send` hook
639
+
640
+ `before_send:` is the last word your code gets on every span and log record
641
+ before it is exported to foam. It runs at the **export boundary** — after the
642
+ batch processor hands foam the finished records, immediately **before**
643
+ foam's redaction pass and the OTLP serialization — so a record your hook
644
+ drops is never serialized and never leaves the process. (This is the same
645
+ seam redaction lives at, and for the same Ruby SDK reason: a span's
646
+ attributes freeze at finish before any `on_finish` processor runs, so a
647
+ `SpanProcessor` could neither mutate nor drop. The export boundary is the
648
+ first point where the record is a mutable struct.)
649
+
650
+ The pipeline order is pinned:
651
+
652
+ ```
653
+ BatchProcessor → before_send hooks → redaction (floor + your lists) → OTLP wire
654
+ ```
655
+
656
+ Your hooks run **first**, on the raw record (it is your own data, still
657
+ in-process), and redaction runs on whatever they return — so `before_send`
658
+ can never widen what ships: an attribute a hook adds is masked by the
659
+ credential floor and your redact lists exactly like one an instrumentation
660
+ set.
661
+
662
+ The per-record contract (Sentry `beforeSend` semantics):
663
+
664
+ * **return the record — or a replacement of the same type** → it continues
665
+ to the next hook / to export. Mutating it in place is supported —
666
+ `record.attributes` arrives as your own unfrozen copy (values included),
667
+ so `record.attributes["k"] = v`, `record.attributes.delete("k")`,
668
+ `record.attributes["k"].gsub!(...)` and (for logs) editing `record.body`
669
+ all just work without bleeding into the span your app still holds or the
670
+ view a tenant processor saw. Events, links and status are the exception:
671
+ **replace them wholesale** (`record.events = [...]`) — never mutate them
672
+ in place, they are shared structures. Foam re-normalizes the record's
673
+ `total_recorded_*` bookkeeping after your hooks run, so adding/deleting
674
+ attributes never corrupts the wire's dropped-count accounting.
675
+ * **return `nil`** → the record is dropped, silently (that is the filter
676
+ mechanism, not an error).
677
+ * **raise, or return a foreign object** → that record is dropped
678
+ **loudly** — one aggregated `[foam]` warning per batch, error classes
679
+ only (messages can carry your data) — and the batch reports failure:
680
+ fail-closed, a faulted hook never ships a record you may have meant to
681
+ scrub, and never raises into your application or export threads. Healthy
682
+ records in the same batch still export. Because a fault marks the whole
683
+ batch failed, a hook that faults **deterministically** also makes the
684
+ at-exit flush report failure — fix faulting hooks promptly (GOTCHAS F16).
685
+
686
+ **Hooks run on foam's export thread, inside the batch processor's export
687
+ lock.** Keep them fast and non-blocking: no network calls, no DB lookups,
688
+ no sleeping — a blocked hook stalls the export pipeline and can wedge the
689
+ at-exit flush. Anything a hook needs from the request/job thread
690
+ (tenant context, user identity) must be stamped onto the record as an
691
+ attribute at capture time (see the `additional_span_processors` stamper
692
+ pattern); thread-locals are gone by the time the hook runs.
693
+
694
+ Boot validation is strict (rule 10): a non-callable — or a **lambda/Method
695
+ whose signature cannot accept one positional argument** (e.g. a Sentry-port
696
+ `->(event, hint) { ... }` with two required params, or a required keyword)
697
+ — raises `ArgumentError` at `init`, because it would otherwise fault on
698
+ every record and ship zero telemetry from a green boot. Make the extra
699
+ Sentry `hint` param optional (`->(event, hint = nil)`) and it is accepted.
700
+
701
+ Scope: **spans and log records** (the two record-shaped signals — the hook
702
+ receives `SpanData` or `LogRecordData`; distinguish them with `is_a?` or
703
+ `respond_to?`). Metrics are aggregated state, not discrete records, and are
704
+ deliberately not routed through `before_send`. Door-2 ingest taps are
705
+ unchanged. Like `enabled:` and the endpoint, `before_send` is process-global:
706
+ the exporter chain is built at the first `init`, and a second `init` never
707
+ changes it.
708
+
709
+ ---
710
+
619
711
  ## The helpers
620
712
 
621
713
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "diagnostics"
5
+
6
+ module Foam
7
+ module Otel
8
+ # The customer's `before_send:` hook(s) (1.8.0), run at the EXPORT
9
+ # BOUNDARY — after batching, immediately BEFORE foam's redaction pass and
10
+ # the OTLP serialization, so a record a hook drops is never serialized
11
+ # and never leaves the process. Why not a SpanProcessor: the same Ruby
12
+ # SDK constraint that forced redaction to the exporter boundary — a span
13
+ # freezes its attributes at finish BEFORE any on_finish processor runs
14
+ # (GOTCHAS F1), so a processor could neither mutate nor reliably drop.
15
+ # The mutable SpanData / LogRecordData Structs the batch processors hand
16
+ # the exporter are the first (and last) point where per-record transform
17
+ # + drop is possible before the wire.
18
+ #
19
+ # Composition order (pinned, pipelines.rb):
20
+ # BatchProcessor -> BeforeSend::*Exporter -> Redacting*Exporter -> OTLP
21
+ # The hooks run FIRST so the credential floor, the value-pattern secret
22
+ # layer and the customer's key lists still apply to whatever a hook
23
+ # returns — before_send can never widen what ships past redaction (an
24
+ # attribute a hook ADDS is masked exactly like one an instrumentation
25
+ # set).
26
+ #
27
+ # Contract per record (Sentry beforeSend semantics — the spec's named
28
+ # fail-closed precedent, BASE_PACKAGE_SPEC D6):
29
+ # * return the record (mutating it in place is fine) → next hook /
30
+ # export;
31
+ # * return nil → record DROPPED (the intentional filter mechanism —
32
+ # success, not a failure);
33
+ # * raise, or return a foreign object → record DROPPED, loudly, and
34
+ # the batch reports FAILURE (fail-closed: a hook fault must never
35
+ # ship a record the customer may have meant to scrub, and must never
36
+ # raise into the export thread — rules 9/14/15). SystemStackError is
37
+ # rescued explicitly (not a StandardError): a hook recursing on a
38
+ # poisoned payload kills neither the batch thread nor the caller.
39
+ # The record reaches the first hook as a shallow dup with an UNFROZEN
40
+ # attributes copy (span attributes freeze at finish — GOTCHAS F1), so
41
+ # `record.attributes["k"] = v` just works; the dup also keeps the
42
+ # customer's mutations off the struct any other consumer might hold.
43
+ #
44
+ # Scope: spans and logs (the two record-shaped signals). Metric data is
45
+ # aggregated state, not records — deliberately NOT routed through
46
+ # before_send (documented in the README).
47
+ module BeforeSend
48
+ # Internal sentinel distinguishing a FAULTED drop (counts toward batch
49
+ # FAILURE) from an intentional nil drop. A hook can never return it
50
+ # legitimately — any non-record return is itself classified a fault.
51
+ ERROR = Object.new.freeze
52
+
53
+ module_function
54
+
55
+ # Run the hook pipeline over one record: each hook receives the
56
+ # previous hook's return. Returns the surviving record, nil for an
57
+ # intentional drop, or ERROR for a faulted one. Fault DETAIL is
58
+ # appended to `faults` (error class / return class only — never a
59
+ # message, which can embed record values) so the exporter can warn
60
+ # ONCE per batch instead of once per record (the redacting exporters'
61
+ # aggregation convention; an unthrottled per-record warn is a stderr
62
+ # flood an adversary-shaped record stream controls — rule 15's
63
+ # "loud but never a flood" posture).
64
+ def run(hooks, record, signal, faults)
65
+ current, counters = thaw(record)
66
+ hooks.each do |hook|
67
+ result = hook.call(current)
68
+ return nil if result.nil?
69
+
70
+ unless result.is_a?(record.class)
71
+ faults << "hook returned #{result.class}"
72
+ return ERROR
73
+ end
74
+ current = result
75
+ end
76
+ restore_counters!(current, counters)
77
+ current
78
+ rescue StandardError, SystemStackError => e
79
+ faults << e.class.name
80
+ # Full detail (message included) only under diagnostics: true —
81
+ # e.message can carry attribute values.
82
+ Diagnostics.info("before_send fault detail (#{signal}): #{e.class}: #{e.message}")
83
+ ERROR
84
+ end
85
+
86
+ # A dup the hook can safely mutate IN PLACE: the struct and the
87
+ # attributes hash are copied, and so are the mutable attribute VALUES
88
+ # (strings, and strings inside string arrays — the OTel attribute
89
+ # types) plus a structured log body — so `record.attributes["k"] = v`,
90
+ # `attributes["k"].gsub!(...)` and `record.body["k"] = v` never bleed
91
+ # into the live span/record the customer still holds or the masked
92
+ # view a tenant processor buffered (pipelines.rb), and never hit a
93
+ # frozen value. Events/links/status are NOT deep-copied: replace them
94
+ # wholesale (`record.events = [...]`), never mutate them in place —
95
+ # documented in the README.
96
+ #
97
+ # Also returns the record's SDK-drop counters, captured BEFORE the
98
+ # hooks run: the OTLP exporters encode dropped_attributes_count as
99
+ # `total_recorded_attributes - attributes.size` (a protobuf uint32),
100
+ # so a hook that ADDS an attribute without the total being fixed up
101
+ # afterwards makes the count NEGATIVE and the encoder drops the WHOLE
102
+ # export request — every healthy record in the batch, silently (the
103
+ # same trap session_stitching.rb documents and guards for foam's own
104
+ # added attributes). restore_counters! below re-normalizes the
105
+ # survivor from these.
106
+ def thaw(record)
107
+ copy = record.dup
108
+ if copy.respond_to?(:attributes) && copy.respond_to?(:attributes=)
109
+ attributes = copy.attributes
110
+ copy.attributes = attributes.nil? ? {} : attributes.transform_values { |v| thaw_value(v) }
111
+ end
112
+ copy.body = thaw_body(copy.body) if copy.respond_to?(:body) && copy.respond_to?(:body=)
113
+ [copy, sdk_drop_counters(record)]
114
+ end
115
+
116
+ def thaw_value(value)
117
+ case value
118
+ when String then value.dup
119
+ when Array then value.map { |e| e.is_a?(String) ? e.dup : e }
120
+ else value
121
+ end
122
+ end
123
+
124
+ # Bounded deep copy of a log body (String / Hash / Array / scalar) so
125
+ # in-place body mutation is safe too; the depth guard mirrors the
126
+ # redaction engine's (rule 9 — an adversarial structure never drives
127
+ # a stack overflow).
128
+ def thaw_body(body, depth = 0)
129
+ return body if depth > MAX_REDACT_DEPTH
130
+
131
+ case body
132
+ when String then body.dup
133
+ when Hash then body.each_with_object({}) { |(k, v), out| out[k] = thaw_body(v, depth + 1) }
134
+ when Array then body.map { |v| thaw_body(v, depth + 1) }
135
+ else body
136
+ end
137
+ end
138
+
139
+ # How many attributes/events/links the SDK itself dropped (limits)
140
+ # BEFORE the hooks ran — nil-safe, clamped at zero.
141
+ def sdk_drop_counters(record)
142
+ {
143
+ attributes: sdk_dropped(record, :total_recorded_attributes, :attributes),
144
+ events: sdk_dropped(record, :total_recorded_events, :events),
145
+ links: sdk_dropped(record, :total_recorded_links, :links),
146
+ }
147
+ end
148
+
149
+ def sdk_dropped(record, total_getter, list_getter)
150
+ return 0 unless record.respond_to?(total_getter)
151
+
152
+ total = record.public_send(total_getter).to_i
153
+ size = collection_size(record, list_getter)
154
+ total > size ? total - size : 0
155
+ end
156
+
157
+ def collection_size(record, list_getter)
158
+ return 0 unless record.respond_to?(list_getter)
159
+
160
+ list = record.public_send(list_getter)
161
+ list.respond_to?(:size) ? list.size.to_i : 0
162
+ rescue StandardError
163
+ 0
164
+ end
165
+
166
+ # Re-normalize the survivor's total_recorded_* bookkeeping after the
167
+ # hooks ran: total = what the record NOW carries + what the SDK had
168
+ # already dropped. Without this, a hook-ADDED attribute makes the
169
+ # OTLP encoder's `total - size` subtraction negative (protobuf uint32
170
+ # → RangeError → the encoder returns nil → the ENTIRE batch is
171
+ # dropped, silently, forever), and a hand-built replacement record
172
+ # with nil totals raises NoMethodError at the same line. Also keeps a
173
+ # hook DELETE honest (the wire no longer claims an SDK-limit drop).
174
+ def restore_counters!(record, counters)
175
+ if record.respond_to?(:total_recorded_attributes=)
176
+ record.total_recorded_attributes = collection_size(record, :attributes) + counters[:attributes]
177
+ end
178
+ if record.respond_to?(:total_recorded_events=)
179
+ record.total_recorded_events = collection_size(record, :events) + counters[:events]
180
+ end
181
+ if record.respond_to?(:total_recorded_links=)
182
+ record.total_recorded_links = collection_size(record, :links) + counters[:links]
183
+ end
184
+ record
185
+ end
186
+
187
+ # The span-side wrapper (composition over the redacting exporter, same
188
+ # wrap-never-subclass posture as redacting_exporter.rb: only the public
189
+ # export/force_flush/shutdown contract the batch processors call).
190
+ class SpanExporter
191
+ def initialize(inner, config)
192
+ @inner = inner
193
+ @hooks = config.before_send
194
+ end
195
+
196
+ def export(span_data, timeout: nil)
197
+ batch = span_data.to_a
198
+ faults = []
199
+ kept = batch.filter_map do |sd|
200
+ result = BeforeSend.run(@hooks, sd, :span, faults)
201
+ result.equal?(ERROR) ? nil : result
202
+ end
203
+ # ONE aggregated warn per batch (the redacting exporters'
204
+ # convention) — class names only, never messages/values.
205
+ unless faults.empty?
206
+ Diagnostics.warn("before_send faulted for #{faults.length} span record(s) — dropped, never " \
207
+ "exported half-transformed (fail-closed, rule 14): #{faults.uniq.take(3).join(', ')}")
208
+ end
209
+ status = kept.empty? ? OpenTelemetry::SDK::Trace::Export::SUCCESS : @inner.export(kept, timeout: timeout)
210
+ faults.empty? ? status : OpenTelemetry::SDK::Trace::Export::FAILURE
211
+ rescue StandardError, SystemStackError
212
+ # Fail closed past the per-record guards, loudly (rule 15) —
213
+ # never raise into the batch thread.
214
+ Diagnostics.warn("before_send pass raised past its guards — span batch dropped (fail-closed, rule 14/15)")
215
+ OpenTelemetry::SDK::Trace::Export::FAILURE
216
+ end
217
+
218
+ def force_flush(timeout: nil) = @inner.force_flush(timeout: timeout)
219
+ def shutdown(timeout: nil) = @inner.shutdown(timeout: timeout)
220
+ end
221
+
222
+ class LogRecordExporter
223
+ def initialize(inner, config)
224
+ @inner = inner
225
+ @hooks = config.before_send
226
+ end
227
+
228
+ def export(log_record_data, timeout: nil)
229
+ batch = log_record_data.to_a
230
+ faults = []
231
+ kept = batch.filter_map do |lrd|
232
+ result = BeforeSend.run(@hooks, lrd, :log, faults)
233
+ result.equal?(ERROR) ? nil : result
234
+ end
235
+ unless faults.empty?
236
+ Diagnostics.warn("before_send faulted for #{faults.length} log record(s) — dropped, never " \
237
+ "exported half-transformed (fail-closed, rule 14): #{faults.uniq.take(3).join(', ')}")
238
+ end
239
+ status = kept.empty? ? OpenTelemetry::SDK::Logs::Export::SUCCESS : @inner.export(kept, timeout: timeout)
240
+ faults.empty? ? status : OpenTelemetry::SDK::Logs::Export::FAILURE
241
+ rescue StandardError, SystemStackError
242
+ Diagnostics.warn("before_send pass raised past its guards — log batch dropped (fail-closed, rule 14/15)")
243
+ OpenTelemetry::SDK::Logs::Export::FAILURE
244
+ end
245
+
246
+ def force_flush(timeout: nil) = @inner.force_flush(timeout: timeout)
247
+ def shutdown(timeout: nil) = @inner.shutdown(timeout: timeout)
248
+ end
249
+ end
250
+ end
251
+ end
@@ -13,6 +13,7 @@ module Foam
13
13
  :name, :environment, :version, :enabled,
14
14
  :redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
15
15
  :diagnostics, :endpoint, :secret_heuristics, :capture_payloads,
16
+ :before_send,
16
17
  keyword_init: true
17
18
  )
18
19
 
@@ -40,7 +41,8 @@ module Foam
40
41
  redact_detect: [].freeze,
41
42
  ignored_outbound_hosts: [].freeze,
42
43
  diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
43
- secret_heuristics: true, capture_payloads: :off
44
+ secret_heuristics: true, capture_payloads: :off,
45
+ before_send: [].freeze
44
46
  ).freeze
45
47
  end
46
48
 
@@ -53,7 +55,7 @@ module Foam
53
55
  def resolve_config(name:, environment:, version:, enabled:,
54
56
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
55
57
  diagnostics:, endpoint:, secret_heuristics: true, redact: nil,
56
- capture_payloads: :off)
58
+ capture_payloads: :off, before_send: nil)
57
59
  keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
58
60
  # capture_payloads is validated LOUDLY at boot (rule 10, exactly like
59
61
  # the redact: object): a value outside CAPTURE_PAYLOAD_MODES (or its
@@ -89,12 +91,82 @@ module Foam
89
91
  # — zero teeing, zero middleware) | :errors | :always, canonical
90
92
  # symbol form. init.rb applies the FOAM_CAPTURE_PAYLOADS operator
91
93
  # env clamp BEFORE this resolves.
92
- capture_payloads: capture_mode
94
+ capture_payloads: capture_mode,
95
+ # The customer's export hook(s) (1.8.0): one callable or an Array
96
+ # pipeline, validated LOUDLY at boot (rule 10 — a non-callable is a
97
+ # programming error). Stored as a frozen Array; empty (the default)
98
+ # means the exporter chain gains no before_send stage at all.
99
+ before_send: validate_before_send!(before_send)
93
100
  ).freeze
94
101
  end
95
102
 
96
103
  private
97
104
 
105
+ # ---- the before_send option (1.8.0) ------------------------------------
106
+ # Loud at boot (rule 10, same posture as capture_payloads and the
107
+ # redact: object): before_send must be one callable (responds to
108
+ # #call — a lambda, a Proc, a Method, or any hook object) or an Array
109
+ # of callables run as a pipeline. Anything else — including a nil or
110
+ # non-callable INSIDE the array — raises here, never a silent no-op
111
+ # that leaves the customer believing their filter runs. The array is
112
+ # COPIED before freezing (never freeze the caller's own object).
113
+ #
114
+ # Arity is validated too, where Ruby lets us introspect it: a lambda
115
+ # or Method that cannot accept ONE positional argument (a Sentry-port
116
+ # `->(event, hint) { }`, a forgotten-param `-> { }`, a required
117
+ # keyword) would pass a bare respond_to?(:call) check and then fault
118
+ # on EVERY record at export — a green boot that ships zero telemetry
119
+ # forever. That is exactly the boot-detectable programming error rule
120
+ # 10 exists for. Non-lambda Procs are arity-forgiving by language
121
+ # semantics and uninspectable callables get the benefit of the doubt.
122
+ def validate_before_send!(value, context: "Foam::Otel.init")
123
+ hooks = if value.nil?
124
+ []
125
+ else
126
+ value.is_a?(Array) ? value.dup : [value]
127
+ end
128
+ hooks.each_with_index do |hook, index|
129
+ unless hook.respond_to?(:call)
130
+ raise ArgumentError, "#{context} before_send: must be a callable (responds to #call) or an " \
131
+ "Array of callables, got #{hook.class} at position #{index}"
132
+ end
133
+ next if before_send_arity_ok?(hook)
134
+
135
+ raise ArgumentError, "#{context} before_send: the callable at position #{index} cannot be " \
136
+ "called with one record argument (it takes a different number of " \
137
+ "required positional parameters, or requires keywords) — before_send " \
138
+ "hooks are called as hook.call(record)"
139
+ end
140
+ hooks.freeze
141
+ end
142
+
143
+ # True when the hook can be invoked as hook.call(record). Lambdas and
144
+ # Methods enforce their signatures, so they are checked; non-lambda
145
+ # Procs coerce arguments (always fine); anything whose #call cannot
146
+ # be introspected passes (never a false boot failure).
147
+ def before_send_arity_ok?(hook)
148
+ params = if hook.is_a?(Proc)
149
+ return true unless hook.lambda?
150
+
151
+ hook.parameters
152
+ elsif hook.is_a?(Method) || hook.is_a?(UnboundMethod)
153
+ hook.parameters
154
+ else
155
+ begin
156
+ hook.method(:call).parameters
157
+ rescue StandardError
158
+ return true
159
+ end
160
+ end
161
+ return false if params.any? { |kind, _| kind == :keyreq }
162
+
163
+ required = params.count { |kind, _| kind == :req }
164
+ flexible = params.any? { |kind, _| kind == :opt || kind == :rest }
165
+ required == 1 || (required.zero? && flexible)
166
+ rescue StandardError
167
+ true
168
+ end
169
+
98
170
  # ---- the capture_payloads option (payload-capture mandate 2026-07-28)
99
171
  # Canonicalize a mode value: the three symbols, or their string
100
172
  # spellings trimmed + case-insensitive, map to the canonical symbol;
@@ -212,7 +284,11 @@ module Foam
212
284
  # the first init: middleware insertion already happened (or
213
285
  # deliberately did not — an :off boot shipped none), so a second
214
286
  # init cannot meaningfully flip it. Carried forward unchanged.
215
- capture_payloads: existing.capture_payloads
287
+ capture_payloads: existing.capture_payloads,
288
+ # before_send is process-global too: the exporter chain was built
289
+ # (with or without its before_send stage) at the first init and a
290
+ # second init never rebuilds pipelines. Carried forward unchanged.
291
+ before_send: existing.before_send
216
292
  ).freeze
217
293
  end
218
294
  end
@@ -59,7 +59,8 @@ module Foam
59
59
  ignored_outbound_hosts: nil,
60
60
  diagnostics: false,
61
61
  secret_heuristics: true,
62
- capture_payloads: :off)
62
+ capture_payloads: :off,
63
+ before_send: nil)
63
64
  # Identity is validated at boot (rule 10): a blank required value is a
64
65
  # programming error the engineer must catch on their machine.
65
66
  validate_present!(:name, name)
@@ -96,7 +97,10 @@ module Foam
96
97
  # FOAM_CAPTURE_PAYLOADS operator env clamp applied first (a valid
97
98
  # env value overrides the option in BOTH directions; an invalid
98
99
  # one warns + falls back — never a crashed boot).
99
- capture_payloads: resolve_capture_payloads(capture_payloads)
100
+ capture_payloads: resolve_capture_payloads(capture_payloads),
101
+ # The customer's export hook(s): a non-callable raises INSIDE
102
+ # resolve_config, loudly, at boot (rule 10).
103
+ before_send: before_send
100
104
  )
101
105
  # The heuristic-tier opt-out is an explicit, audited customer decision
102
106
  # (security-fixes-design §V.8) — always one loud line, never silent.
@@ -8,6 +8,7 @@ require "delegate"
8
8
  require "openssl"
9
9
 
10
10
  require_relative "redacting_exporter"
11
+ require_relative "before_send"
11
12
  require_relative "session_stitching"
12
13
 
13
14
  module Foam
@@ -33,6 +34,11 @@ module Foam
33
34
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
34
35
  )
35
36
  exporter = RedactingSpanExporter.new(inner, config)
37
+ # The customer's before_send hooks (1.8.0) wrap OUTSIDE redaction:
38
+ # batch → before_send → redaction → OTLP, so the hooks see the record
39
+ # first and the credential floor still masks whatever they return
40
+ # (before_send.rb). No hooks configured → no stage at all.
41
+ exporter = BeforeSend::SpanExporter.new(exporter, config) unless config.before_send.empty?
36
42
  # Pin the sampler explicitly: the TracerProvider constructor otherwise
37
43
  # reads OTEL_TRACES_SAMPLER, which an operator (or a stale fleet-wide
38
44
  # var) could set to always_off and silently drop EVERY foam span
@@ -87,6 +93,8 @@ module Foam
87
93
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER # F-RB1 pin — see setup_traces
88
94
  )
89
95
  exporter = RedactingLogRecordExporter.new(inner, config)
96
+ # before_send stage, same shape and ordering as setup_traces above.
97
+ exporter = BeforeSend::LogRecordExporter.new(exporter, config) unless config.before_send.empty?
90
98
  # Pin the log-record limits at the SDK defaults — LogRecordLimits
91
99
  # otherwise reads OTEL_LOGRECORD_ATTRIBUTE_COUNT_LIMIT /
92
100
  # OTEL_(LOGRECORD_)ATTRIBUTE_VALUE_LENGTH_LIMIT (same env family as
@@ -109,6 +109,42 @@ module Foam
109
109
  # customer redact lists reach inside the body JSON). Rails auto-wiring
110
110
  # (Railtie + init fallback) inserts the middleware ONLY when the mode
111
111
  # is not :off; plain Rack adds one `use` line.
112
- VERSION = "1.7.0"
112
+ # 1.8.0: `before_send:` EXPORT HOOK (MINOR — one additive init option,
113
+ # default absent, zero default-path behavior change). One callable or
114
+ # an Array pipeline, run per record at the EXPORT BOUNDARY — after
115
+ # batching, BEFORE foam's redaction pass and the OTLP serialization —
116
+ # so a dropped record never leaves the process, and whatever a hook
117
+ # returns still rides the full credential floor / value layer /
118
+ # customer-key pass (before_send can never widen what ships). Spans
119
+ # and logs only (metrics are aggregated state, not records). Return
120
+ # the record (in-place mutation supported — attributes arrive
121
+ # unfrozen) to keep it, nil to drop it; a hook that raises or returns
122
+ # a foreign object drops the record LOUDLY and the batch reports
123
+ # FAILURE (fail-closed, Sentry beforeSend precedent, spec D6). A
124
+ # non-callable raises at boot; process-global like enabled/endpoint
125
+ # (a second init never changes it). Door-1 only — the door-2 ingest
126
+ # taps are unchanged.
127
+ # 1.8.1: before_send REVIEW HARDENING (adversarial review, 21 confirmed
128
+ # findings; PATCH — bugfix-only, no API change). CRITICAL: the OTLP
129
+ # encoders derive dropped_attributes_count as total_recorded - size
130
+ # (protobuf uint32) — a hook-ADDED attribute made the count negative,
131
+ # the encoder raised, and the WHOLE batch (healthy siblings included)
132
+ # was silently dropped; foam now re-normalizes total_recorded_* on
133
+ # every surviving record (nil-safe for hand-built replacement records;
134
+ # hook DELETES no longer fake an SDK-limit drop) — GOTCHAS F16. thaw
135
+ # now copies attribute VALUES and deep-copies structured log bodies
136
+ # (in-place mutation never bleeds into the live record or the tenant
137
+ # masked view; frozen values never fault). Boot validation gains an
138
+ # arity check: a lambda/Method that cannot take one positional arg
139
+ # (the Sentry ->(event, hint) port) raises at init instead of
140
+ # faulting on every record. Hook faults warn ONCE per batch (error
141
+ # classes only — messages can carry values; detail under
142
+ # diagnostics). validate_before_send! copies the caller's array
143
+ # (never freezes customer-owned input). Same-type replacement records
144
+ # documented (always accepted). Champ scenario spec: CI
145
+ # service-container fallback, pg_isready gate, leak cleanup, numeric
146
+ # version pick. Hooks run on the export thread — non-blocking only
147
+ # (documented, GOTCHAS F16).
148
+ VERSION = "1.8.1"
113
149
  end
114
150
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
@@ -378,6 +378,7 @@ files:
378
378
  - lib/foam-otel.rb
379
379
  - lib/foam/otel.rb
380
380
  - lib/foam/otel/api.rb
381
+ - lib/foam/otel/before_send.rb
381
382
  - lib/foam/otel/classifier.rb
382
383
  - lib/foam/otel/config.rb
383
384
  - lib/foam/otel/constants.rb