foam-otel 1.8.0 → 1.9.0

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: 3e034361554ad48530554a1bdc62d75adf1899e3ef57104a367abd2c412d2f93
4
- data.tar.gz: 40b6db9d7f11ed9105b00c9998b87da51e0822480d97da52be766ddea24435ca
3
+ metadata.gz: d27bb6b38712364503503fecd37ea383f9a7e3fb3648da8ef41524c575ef9302
4
+ data.tar.gz: 0c9fee962999043120904c8ffe85621e32ae04fcb300280ec8af0b2c77291a0d
5
5
  SHA512:
6
- metadata.gz: 9c71df1b9ce568dabd996b659d13e0bc05e93a81e070376a77e8c09ccd6a6fc51dcaa028c19a904e78d38daae532c6bc75077e3fa5331047aa1c64e08a9609ee
7
- data.tar.gz: 90623d934372eb908d8c9d13e5ddd4108faad54bea4c5a647e7934959562ff130c2cdfdc2f0c5c5cc00a1a86eb5fcc4ee03c745c69c8c258fced2caf6ceb2f31
6
+ metadata.gz: 8bdcb84ca5d9becc44e9a181512ac19c9c8c24f5035e7da9da09a1ab59a4c09cd96b9cde80a8b5d073001ae6f36d9f4e1b3e1a3e640c4eafa6d30ac34800e47c
7
+ data.tar.gz: b09d59343870e8d16ef9e4c0794e7ec78486d5ab9c3eea339d99c4f8b4fa46f58ca5246ca3df70ed8a82c795fd4108c296c3bb19e720640c5acce080f0e90a98
data/GOTCHAS.md CHANGED
@@ -808,6 +808,193 @@ 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
+ ## F17: There is no unwrapped Net::HTTP instance — foam's export must own its transport (export isolation, 2026-07-29)
870
+
871
+ - **Trap**: Ruby APM agents instrument by PREPENDING modules onto `Net::HTTP`
872
+ itself, so every instance in the process — including the one inside the
873
+ upstream OTLP exporter — dispatches through the foreign prepend chain, and
874
+ private helper names from different vendors resolve against each other on
875
+ the shared ancestry (the wrong-arity `annotate_span_with_response!`
876
+ collision class: every export crashes, zero rows land, the app looks
877
+ healthy). The upstream exporter's `untraced` suppression does NOT protect
878
+ the send — foreign agents never check OTel's flag; the exporter died with
879
+ it active. Reproduced at production parity against a real proprietary
880
+ auto-instrumenting agent (docs/decisions/export-isolation-ruby.md, with
881
+ before/after crash and rows-landed counts).
882
+ - **Sources**:
883
+ - Installed source — the vulnerable transport: `Net::HTTP.new` inside
884
+ `http_connection` and the `Net::HTTPResponse`-matching send loop
885
+ (opentelemetry-exporter-otlp `exporter.rb:129,150-258`; same shape in
886
+ -otlp-logs `logs_exporter.rb` and -otlp-metrics `metrics_exporter.rb` —
887
+ with drifting private `backoff?` signatures across the three gems).
888
+ - Design reference (strongest industry mechanism, isolation by
889
+ construction): the Go OTLP exporter's owned internal transport and its
890
+ documented warning against injecting an instrumented transport; the
891
+ error-tracking vendors' native-fetch browser transports.
892
+ - The prior collision incident class: the upstream contrib `net_http`
893
+ patch's private helper namespace
894
+ (opentelemetry-instrumentation-net_http `patches/stable/instrumentation.rb:81`).
895
+ - **Decision & why**: Isolation by construction, layered over suppression —
896
+ never suppression alone. `Foam::Otel::IsolatedHttpClient` is a hand-written
897
+ HTTP/1.1 sender over foam-owned `TCPSocket`/`OpenSSL::SSL::SSLSocket`
898
+ (VERIFY_PEER + hostname verification, CA/mTLS options, HTTP(S)_PROXY /
899
+ NO_PROXY with CONNECT tunneling, deadline IO, keep-alive with one
900
+ stale-socket retransmit, chunked/`Connection: close` reading, pid-keyed
901
+ reconnect for fork safety). `IsolatedExporters::{Trace,Logs,Metrics}Exporter`
902
+ subclass the upstream exporters, replacing exactly `http_connection` and
903
+ `send_bytes` (foam-owned retry loop, upstream semantics verbatim: 429/503
904
+ honor Retry-After, 408/502/504 + transient socket errors back off with
905
+ jitter, other 4xx drop; `untraced` kept as layer two). All six construction
906
+ sites (door 1 + door 2 × three signals) — the package's entire outbound
907
+ surface — build these subclasses. Capture is untouched; and foam's own
908
+ prepends (LLM shims, Logger bridge, fork hook) define ONLY the intercepted
909
+ public method, so foam can never be the colliding party on anyone's class.
910
+ - **Mitigation**: a foreign prepend chain on `Net::HTTP` structurally cannot
911
+ sit in foam's delivery path; a hostile wrong-arity prepend that crashes
912
+ every ordinary `Net::HTTP` caller leaves foam's rows landing.
913
+ - **Test**: `spec/export_isolation_spec.rb` (hostile prepend in a forked
914
+ child + spy-prepend never-dispatches proof, 503/429 Retry-After retries,
915
+ CONNECT-tunnel and absolute-form proxy paths, NO_PROXY, TLS wrong-hostname
916
+ rejection, chunked/keep-alive/stale-socket/deadline/fork-safety units, the
917
+ untraced layer-two pin, and the zero-collidable-private-helpers invariant);
918
+ `spec/export_isolation_hardening_spec.rb` (protocol edges: 1xx skip,
919
+ duplicate/trailered/extension-chunked responses, EOF-framed bodies,
920
+ keep-alive expiry, malformed status lines, IPv6 Host framing, 8-thread
921
+ concurrency; retry accounting with the backoff clock stubbed: exact
922
+ retry-cap exhaustion, Retry-After delta AND HTTP-date, 408/502/504,
923
+ redirects never followed, zero/mid-loop budget exhaustion; compression
924
+ none + its env var; loud non-protobuf 5xx handling; shutdown lifecycle;
925
+ mTLS e2e via the spec CERTIFICATE/CLIENT_CERTIFICATE/CLIENT_KEY env vars
926
+ incl. the rejected no-client-cert half; proxy credentials on CONNECT,
927
+ refused CONNECT, lowercase env twins, the NO_PROXY shape matrix; all
928
+ three signals through a full init, door-2 tap e2e, and a REAL fork);
929
+ `spec/ingest_spec.rb` (case 4 half 3: zero feedback spans with BOTH
930
+ cooperative layers defeated); `spec/transport_spec.rb` (the exact isolated
931
+ classes on all six sites); and the REAL-agent coexistence gate
932
+ `test-apps/ruby-coexistence/run-verify.sh` (two postures booting the
933
+ actual commercial agents in auto-instrument mode, hard-gated on the
934
+ vendor module being observed live ahead of Net::HTTP in the running
935
+ process's ancestor chain, verdict = protobuf-decoded rows on all three
936
+ signals with zero export errors).
937
+
938
+ ---
939
+
940
+ ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29)
941
+
942
+ - **Trap**: Every module prepended onto a class shares ONE private-method
943
+ namespace. When two observability vendors prepend same-named private
944
+ helpers at different signatures onto Net::HTTP, method lookup hands EVERY
945
+ caller the frontmost definition — one vendor's wrapper invokes the other
946
+ vendor's helper and raises ArgumentError on EVERY Net::HTTP request the
947
+ app makes. This is live in the wild today: a proprietary agent's wrapper
948
+ and the upstream OTel contrib net_http patch both define
949
+ `annotate_span_with_response!` (3-arg vs 2-arg). Foam ships that contrib
950
+ patch for capture, so a foam + agent process CONTAINS the collision — and
951
+ foam's promise is coexistence: the app must keep working.
952
+ - **Sources**:
953
+ - Installed source — the two colliding definitions:
954
+ opentelemetry-instrumentation-net_http 0.29.0
955
+ `patches/stable/instrumentation.rb:81` (`annotate_span_with_response!(span, response)`)
956
+ and the proprietary agent's contrib HTTP instrumentation calling its own
957
+ 3-arg spelling of the same private name (verified live in the
958
+ coexistence gate; backtrace pinned in
959
+ docs/decisions/export-isolation-ruby.md §A).
960
+ - Ruby semantics: prepended modules join the class's ancestor chain and
961
+ private methods resolve through the SAME chain for every caller — there
962
+ is no per-module helper namespace.
963
+ - **Decision & why**: foam cannot rename the upstream helper (its own caller
964
+ resolves the name through the same shared chain — a rename breaks the
965
+ gem being "fixed"), and foam must never edit another vendor's module. So
966
+ foam owns the collision instead: after the instrumentation sweep, init
967
+ scans Net::HTTP's prepend chain for private-helper names defined by two
968
+ or more foreign modules at NON-identical positional signatures (names the
969
+ class itself defines are super-chains, never shielded) and prepends ONE
970
+ frontmost dispatch method per colliding name — routing by caller source
971
+ gem first, then by signature fit, falling back to the previously-frontmost
972
+ definition (status quo ante; the shield never makes a broken chain
973
+ worse). Its dispatch methods accept (*args, **kwargs, &block), so nothing
974
+ can arity-crash against the shield itself. No collision → NOTHING is
975
+ installed. Every shielded name is announced loudly (rule 15). This is the
976
+ ONE deliberate exception to foam's own-prepend hygiene rule (F17's
977
+ "foam's prepends carry zero private helpers"): the shield's whole job is
978
+ to own the already-colliding names.
979
+ - **Mitigation**: with the shield installed the coexistence gate's
980
+ patched-client probe went from 3/3 crashes to 3/3 passes under the real
981
+ agent, with both vendors' helpers verifiably executing again.
982
+ - **Residual**: an agent that patches AFTER foam's init lands ahead of the
983
+ shield and re-exposes the raw collision until a later init/fork re-scan;
984
+ agents overwhelmingly boot first (initializer/preload) — the order the
985
+ coexistence gate proves. Identical-signature collisions are left alone
986
+ (no crash class; dispatch could not disambiguate semantics).
987
+ - **Test**: `spec/prepend_collision_shield_spec.rb` (the unshielded RED
988
+ control, both-vendors-work dispatch, idempotence, no-op purity on
989
+ collision-free chains, super-chain and identical-signature exclusions,
990
+ fall-through to the real error, rule-9 scan degradation, caller-source
991
+ dispatch across two on-disk gem roots, and the real Net::HTTP + real
992
+ contrib patch field scenario red→green in a forked child);
993
+ `test-apps/ruby-coexistence/run-verify.sh` (the patched-client probe is a
994
+ REQUIRED pass on every vendor posture).
995
+
996
+ ---
997
+
811
998
  ## General gotchas (applicable to Ruby)
812
999
 
813
1000
  - **G1 — init after target import / pre-init no-op**: the API's proxy providers
data/README.md CHANGED
@@ -331,7 +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 to export it (mutating it in place is supported `record.attributes` arrives unfrozen), or `nil` to drop it. A hook that raises (or returns anything else) drops **that record** loudly and the batch reports failure — fail-closed, never a raise into your threads. Runs BEFORE redaction, so the credential floor and your redact lists still apply to whatever the hook returns. Spans and logs only (metrics are aggregated state, not records). A non-callable raises at boot. Process-global: a second `init` never changes it. See "The `before_send` hook" below. |
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. |
335
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. |
336
336
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
337
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). |
@@ -661,17 +661,42 @@ set.
661
661
 
662
662
  The per-record contract (Sentry `beforeSend` semantics):
663
663
 
664
- * **return the record** it continues to the next hook / to export.
665
- Mutating it in place is supported — `record.attributes` arrives as an
666
- unfrozen copy, so `record.attributes["k"] = v` and
667
- `record.attributes.delete("k")` just work.
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.
668
675
  * **return `nil`** → the record is dropped, silently (that is the filter
669
676
  mechanism, not an error).
670
- * **raise, or return anything else** → that record is dropped **loudly**
671
- (`[foam]` warning) and the batch reports failure — fail-closed: a faulted
672
- hook never ships a record you may have meant to scrub, and never raises
673
- into your application or export threads. Healthy records in the same batch
674
- still export.
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.
675
700
 
676
701
  Scope: **spans and log records** (the two record-shaped signals — the hook
677
702
  receives `SpanData` or `LogRecordData`; distinguish them with `is_a?` or
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constants"
3
4
  require_relative "diagnostics"
4
5
 
5
6
  module Foam
@@ -53,37 +54,134 @@ module Foam
53
54
 
54
55
  # Run the hook pipeline over one record: each hook receives the
55
56
  # previous hook's return. Returns the surviving record, nil for an
56
- # intentional drop, or ERROR for a faulted one.
57
- def run(hooks, record, signal)
58
- current = thaw(record)
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)
59
66
  hooks.each do |hook|
60
67
  result = hook.call(current)
61
68
  return nil if result.nil?
62
69
 
63
70
  unless result.is_a?(record.class)
64
- Diagnostics.warn("before_send returned a #{result.class} for a #{signal} record — record " \
65
- "dropped (return the record, a replacement of the same type, or nil to drop; " \
66
- "fail-closed, rule 14)")
71
+ faults << "hook returned #{result.class}"
67
72
  return ERROR
68
73
  end
69
74
  current = result
70
75
  end
76
+ restore_counters!(current, counters)
71
77
  current
72
78
  rescue StandardError, SystemStackError => e
73
- Diagnostics.warn("before_send raised for a #{signal} record — record dropped, never exported " \
74
- "half-transformed (fail-closed, rule 14): #{e.class}: #{e.message}")
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}")
75
83
  ERROR
76
84
  end
77
85
 
78
- # A shallow dup with an unfrozen attributes copy the hook gets its
79
- # own mutable record (span attributes arrive frozen; GOTCHAS F1).
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.
80
106
  def thaw(record)
81
107
  copy = record.dup
82
108
  if copy.respond_to?(:attributes) && copy.respond_to?(:attributes=)
83
109
  attributes = copy.attributes
84
- copy.attributes = attributes.nil? ? {} : attributes.dup
110
+ copy.attributes = attributes.nil? ? {} : attributes.transform_values { |v| thaw_value(v) }
85
111
  end
86
- copy
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
87
185
  end
88
186
 
89
187
  # The span-side wrapper (composition over the redacting exporter, same
@@ -97,18 +195,19 @@ module Foam
97
195
 
98
196
  def export(span_data, timeout: nil)
99
197
  batch = span_data.to_a
100
- faulted = 0
198
+ faults = []
101
199
  kept = batch.filter_map do |sd|
102
- result = BeforeSend.run(@hooks, sd, :span)
103
- if result.equal?(ERROR)
104
- faulted += 1
105
- nil
106
- else
107
- result
108
- end
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(', ')}")
109
208
  end
110
209
  status = kept.empty? ? OpenTelemetry::SDK::Trace::Export::SUCCESS : @inner.export(kept, timeout: timeout)
111
- faulted.positive? ? OpenTelemetry::SDK::Trace::Export::FAILURE : status
210
+ faults.empty? ? status : OpenTelemetry::SDK::Trace::Export::FAILURE
112
211
  rescue StandardError, SystemStackError
113
212
  # Fail closed past the per-record guards, loudly (rule 15) —
114
213
  # never raise into the batch thread.
@@ -128,18 +227,17 @@ module Foam
128
227
 
129
228
  def export(log_record_data, timeout: nil)
130
229
  batch = log_record_data.to_a
131
- faulted = 0
230
+ faults = []
132
231
  kept = batch.filter_map do |lrd|
133
- result = BeforeSend.run(@hooks, lrd, :log)
134
- if result.equal?(ERROR)
135
- faulted += 1
136
- nil
137
- else
138
- result
139
- end
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(', ')}")
140
238
  end
141
239
  status = kept.empty? ? OpenTelemetry::SDK::Logs::Export::SUCCESS : @inner.export(kept, timeout: timeout)
142
- faulted.positive? ? OpenTelemetry::SDK::Logs::Export::FAILURE : status
240
+ faults.empty? ? status : OpenTelemetry::SDK::Logs::Export::FAILURE
143
241
  rescue StandardError, SystemStackError
144
242
  Diagnostics.warn("before_send pass raised past its guards — log batch dropped (fail-closed, rule 14/15)")
145
243
  OpenTelemetry::SDK::Logs::Export::FAILURE
@@ -108,22 +108,65 @@ module Foam
108
108
  # #call — a lambda, a Proc, a Method, or any hook object) or an Array
109
109
  # of callables run as a pipeline. Anything else — including a nil or
110
110
  # non-callable INSIDE the array — raises here, never a silent no-op
111
- # that leaves the customer believing their filter runs.
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.
112
122
  def validate_before_send!(value, context: "Foam::Otel.init")
113
123
  hooks = if value.nil?
114
124
  []
115
125
  else
116
- value.is_a?(Array) ? value : [value]
126
+ value.is_a?(Array) ? value.dup : [value]
117
127
  end
118
128
  hooks.each_with_index do |hook, index|
119
- next if hook.respond_to?(:call)
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)
120
134
 
121
- raise ArgumentError, "#{context} before_send: must be a callable (responds to #call) or an " \
122
- "Array of callables, got #{hook.class} at position #{index}"
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)"
123
139
  end
124
140
  hooks.freeze
125
141
  end
126
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
+
127
170
  # ---- the capture_payloads option (payload-capture mandate 2026-07-28)
128
171
  # Canonicalize a mode value: the three symbols, or their string
129
172
  # spellings trimmed + case-insensitive, map to the canonical symbol;
@@ -285,23 +285,25 @@ module Foam
285
285
  # CWE-295 — see pipelines.rb setup_traces): door 2 carries the same
286
286
  # fleet ingest token over the same TLS connection, so the silent
287
287
  # OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE downgrade is pinned out
288
- # here identically.
288
+ # here identically. All three are IsolatedExporters subclasses —
289
+ # door 2's delivery must be exactly as immune to foreign Net::HTTP
290
+ # prepends as door 1's (export-isolation design, 2026-07-29).
289
291
  def build_otlp_span_exporter(endpoint, headers)
290
- OpenTelemetry::Exporter::OTLP::Exporter.new(
292
+ IsolatedExporters::TraceExporter.new(
291
293
  endpoint: "#{endpoint}/v1/traces", headers: headers,
292
294
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
293
295
  )
294
296
  end
295
297
 
296
298
  def build_otlp_log_exporter(endpoint, headers)
297
- OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
299
+ IsolatedExporters::LogsExporter.new(
298
300
  endpoint: "#{endpoint}/v1/logs", headers: headers,
299
301
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
300
302
  )
301
303
  end
302
304
 
303
305
  def build_otlp_metric_exporter(endpoint, headers)
304
- OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
306
+ IsolatedExporters::MetricsExporter.new(
305
307
  endpoint: "#{endpoint}/v1/metrics", headers: headers,
306
308
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
307
309
  )
@@ -528,6 +528,10 @@ module Foam
528
528
  require "opentelemetry-exporter-otlp"
529
529
  require "opentelemetry-exporter-otlp-logs"
530
530
  require "opentelemetry-exporter-otlp-metrics"
531
+ # The isolated transport subclasses (export-isolation design) load
532
+ # HERE — after the upstream exporter gems they subclass — so the lazy
533
+ # rule-41 posture holds: a disabled boot never pays their cost.
534
+ require_relative "isolated_exporters"
531
535
  end
532
536
 
533
537
  # The central RAW-capture activation config (raised-floor ruling
@@ -610,6 +614,12 @@ module Foam
610
614
  FLOOR_INSTRUMENTATION_CONFIG.merge(SUPERSEDED_INSTRUMENTATION_CONFIG)
611
615
  )
612
616
  Diagnostics.info("instrumentations installed: #{gems.length} bundled gem(s)")
617
+ # Coexistence shield (2026-07-29, GOTCHAS F18): with the sweep done,
618
+ # every prepend that will sit on Net::HTTP in the realistic boot
619
+ # order (agent first, foam second) is in place — scan for cross-
620
+ # vendor private-helper collisions and shim them. A collision-free
621
+ # chain (the common case) installs NOTHING.
622
+ PrependCollisionShield.install!(::Net::HTTP) if defined?(::Net::HTTP)
613
623
  rescue StandardError => e
614
624
  Diagnostics.warn("instrumentation activation failed: #{e.class}: #{e.message}")
615
625
  end