foam-otel 1.8.1 → 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: 6ef766d3509a7ee5daa25c7f53dce352b9c67c8ed22283099c72f3d6f0b8e957
4
- data.tar.gz: caca5adca96f4e3dab3d0610ce54000858cb231cbf167e5bb6dd0c6f1dcd6a9b
3
+ metadata.gz: d27bb6b38712364503503fecd37ea383f9a7e3fb3648da8ef41524c575ef9302
4
+ data.tar.gz: 0c9fee962999043120904c8ffe85621e32ae04fcb300280ec8af0b2c77291a0d
5
5
  SHA512:
6
- metadata.gz: 55da8fd55a07d287d81fddf5a65eeb8b7e0f743d1ec8b034362972f5c02a15a84bab18119aacf9e881b4deefc61d6958e062990fe3b205f66f2a88fd11ba514b
7
- data.tar.gz: 70e46934c6250308cb8d15c90a160eeab5ce916b294cfc7395e46fc07e0d8d2027ded17f3f21cdcf3125c35773fb899324eb0374c6326c04d73a2908e0e1024d
6
+ metadata.gz: 8bdcb84ca5d9becc44e9a181512ac19c9c8c24f5035e7da9da09a1ab59a4c09cd96b9cde80a8b5d073001ae6f36d9f4e1b3e1a3e640c4eafa6d30ac34800e47c
7
+ data.tar.gz: b09d59343870e8d16ef9e4c0794e7ec78486d5ab9c3eea339d99c4f8b4fa46f58ca5246ca3df70ed8a82c795fd4108c296c3bb19e720640c5acce080f0e90a98
data/GOTCHAS.md CHANGED
@@ -866,6 +866,132 @@ exfiltratable — the value-pattern secret layer is the required second control
866
866
  "REAL OTLP encode", "aggregates hook faults", "arity", "in-place value
867
867
  mutation never bleeds"); `spec/before_send_champ_scenario_spec.rb` (the
868
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).
869
995
 
870
996
  ---
871
997
 
@@ -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
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The isolated OTLP exporters (export-isolation design, 2026-07-29). The
4
+ # upstream opentelemetry-exporter-otlp{,-logs,-metrics} gems each build a
5
+ # Net::HTTP inside #http_connection and drive it from #send_bytes — which puts
6
+ # every foreign APM's Net::HTTP prepend chain (Datadog, New Relic, Sentry…)
7
+ # INSIDE foam's delivery path. These subclasses keep everything else the
8
+ # upstream classes do — endpoint/headers/compression/timeout resolution
9
+ # including the spec OTEL_EXPORTER_OTLP_* env vars, protobuf encoding, the
10
+ # ssl/mTLS options — and replace exactly two private methods:
11
+ #
12
+ # * #http_connection → returns Foam::Otel::IsolatedHttpClient (an owned,
13
+ # hand-written HTTP/1.1 sender; see isolated_http_client.rb);
14
+ # * #send_bytes → a foam-owned send loop that reimplements the upstream
15
+ # retry semantics VERBATIM (backoff with jitter on 429/503 honoring
16
+ # Retry-After, backoff on 408/502/504 and transient socket/SSL errors,
17
+ # drop on other 4xx/5xx) against the isolated client. Reimplemented, not
18
+ # inherited, because the upstream loop pattern-matches Net::HTTPResponse
19
+ # classes and rescues Net::* exceptions — and because its private helper
20
+ # signatures drift across the three gems (logs' backoff? has no reason:).
21
+ #
22
+ # Suppression stays as LAYER TWO: the send runs inside
23
+ # Common::Utilities.untraced, so cooperative instrumentation (foam's own
24
+ # included) never traces foam's export even if a future capture hook could
25
+ # see it. Layer one — the reason the Datadog crash cannot recur — is that the
26
+ # bytes never enter Net::HTTP at all.
27
+ require "zlib"
28
+
29
+ require_relative "diagnostics"
30
+ require_relative "isolated_http_client"
31
+
32
+ module Foam
33
+ module Otel
34
+ module IsolatedExporters
35
+ # Export result codes; == OpenTelemetry::SDK::*::Export::SUCCESS/FAILURE
36
+ # in every signal's SDK (integers, so this module never reaches into the
37
+ # per-signal constant trees).
38
+ SUCCESS = 0
39
+ FAILURE = 1
40
+ RETRY_COUNT = 5 # upstream's cap, kept verbatim
41
+
42
+ # The shared send path, included by the three per-signal subclasses
43
+ # below. Relies only on ivars every upstream constructor sets (@http,
44
+ # @path, @headers, @timeout, @compression, @uri) plus the optional
45
+ # @metrics_reporter (absent in the logs exporter — every use is nil-safe).
46
+ module SendPath
47
+ private
48
+
49
+ def http_connection(uri, ssl_verify_mode, certificate_file, client_certificate_file, client_key_file)
50
+ IsolatedHttpClient.new(
51
+ uri,
52
+ ssl_verify_mode: ssl_verify_mode,
53
+ certificate_file: certificate_file,
54
+ client_certificate_file: client_certificate_file,
55
+ client_key_file: client_key_file
56
+ )
57
+ end
58
+
59
+ def send_bytes(bytes, timeout:) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
60
+ return FAILURE if bytes.nil?
61
+
62
+ report_value("otel.otlp_exporter.message.uncompressed_size", bytes.bytesize)
63
+ if @compression == "gzip"
64
+ body = Zlib.gzip(bytes)
65
+ report_value("otel.otlp_exporter.message.compressed_size", body.bytesize)
66
+ else
67
+ body = bytes
68
+ end
69
+ request_headers = @headers.merge("Content-Type" => "application/x-protobuf")
70
+ request_headers["Content-Encoding"] = "gzip" if @compression == "gzip"
71
+
72
+ retry_count = 0
73
+ timeout ||= @timeout
74
+ start_time = OpenTelemetry::Common::Utilities.timeout_timestamp
75
+
76
+ # Layer two: the cooperative suppression flag, kept even though the
77
+ # isolated client makes it structurally unnecessary for delivery —
78
+ # it also stops foam ever tracing its own export.
79
+ OpenTelemetry::Common::Utilities.untraced do
80
+ loop do
81
+ remaining_timeout = OpenTelemetry::Common::Utilities.maybe_timeout(timeout, start_time)
82
+ return FAILURE if remaining_timeout.zero?
83
+
84
+ response = @http.post(@path, body: body, headers: request_headers, timeout: remaining_timeout)
85
+
86
+ case response.code
87
+ when 200..299
88
+ return SUCCESS
89
+ when 429, 503
90
+ next if foam_backoff?(retry_after: response["retry-after"],
91
+ retry_count: retry_count += 1, reason: response.code.to_s)
92
+
93
+ # Retry exhaustion is announced (fleet-review parity fix:
94
+ # upstream's per-retry handle_http_error kept failures
95
+ # visible; foam announces once, at the point data drops).
96
+ foam_log_request_failure(response.code)
97
+ return FAILURE
98
+ when 408, 502, 504
99
+ next if foam_backoff?(retry_count: retry_count += 1, reason: response.code.to_s)
100
+
101
+ foam_log_request_failure(response.code)
102
+ return FAILURE
103
+ when 404
104
+ foam_log_request_failure(response.code)
105
+ return FAILURE
106
+ when 300..399
107
+ # Upstream parity: redirects are not followed (its
108
+ # handle_redirect is a TODO); drop the connection and back off.
109
+ @http.finish
110
+ next if foam_backoff?(retry_after: 0, retry_count: retry_count += 1,
111
+ reason: response.code.to_s)
112
+
113
+ return FAILURE
114
+ when 400..599
115
+ foam_log_status(response.body)
116
+ add_to_failure_counter(response.code.to_s)
117
+ return FAILURE
118
+ else
119
+ @http.finish
120
+ foam_log_request_failure(response.code)
121
+ return FAILURE
122
+ end
123
+ rescue IsolatedHttpClient::TimeoutError
124
+ # Deliberate divergence from upstream (documented): upstream
125
+ # lets Net::WriteTimeout fall to the fatal catch-all while
126
+ # retrying read timeouts; a stalled WRITE is just as transient
127
+ # (proxy hiccup, slow-start collector), so foam retries both
128
+ # within the same budget/backoff caps.
129
+ next if foam_backoff?(retry_count: retry_count += 1, reason: "timeout")
130
+
131
+ return FAILURE
132
+ rescue OpenSSL::SSL::SSLError => e
133
+ next if foam_backoff?(retry_count: retry_count += 1, reason: "openssl_error")
134
+
135
+ OpenTelemetry.handle_error(exception: e, message: "SSL error in isolated OTLP send_bytes")
136
+ return FAILURE
137
+ rescue SocketError, IsolatedHttpClient::ProxyConnectError
138
+ next if foam_backoff?(retry_count: retry_count += 1, reason: "socket_error")
139
+
140
+ return FAILURE
141
+ rescue SystemCallError => e
142
+ next if foam_backoff?(retry_count: retry_count += 1, reason: e.class.name)
143
+
144
+ return FAILURE
145
+ rescue EOFError
146
+ next if foam_backoff?(retry_count: retry_count += 1, reason: "eof_error")
147
+
148
+ return FAILURE
149
+ rescue Zlib::DataError
150
+ next if foam_backoff?(retry_count: retry_count += 1, reason: "zlib_error")
151
+
152
+ return FAILURE
153
+ rescue StandardError => e
154
+ OpenTelemetry.handle_error(exception: e, message: "unexpected error in isolated OTLP send_bytes")
155
+ add_to_failure_counter(e.class.to_s)
156
+ return FAILURE
157
+ end
158
+ end
159
+ end
160
+
161
+ # Upstream backoff semantics verbatim (red-flag 2): count the failure,
162
+ # give up past RETRY_COUNT, honor Retry-After as delta-seconds or
163
+ # HTTP-date, else exponential jitter — foam-prefixed (rule: foam is
164
+ # never the colliding party on anyone's class, its own included).
165
+ def foam_backoff?(retry_count:, reason:, retry_after: nil)
166
+ add_to_failure_counter(reason)
167
+ return false if retry_count > RETRY_COUNT
168
+
169
+ sleep_interval = nil
170
+ unless retry_after.nil?
171
+ sleep_interval = Integer(retry_after, exception: false)
172
+ sleep_interval ||=
173
+ begin
174
+ Time.httpdate(retry_after) - Time.now
175
+ rescue StandardError
176
+ nil
177
+ end
178
+ sleep_interval = nil unless sleep_interval&.positive?
179
+ end
180
+ sleep_interval ||= rand(2**retry_count)
181
+
182
+ sleep(sleep_interval)
183
+ true
184
+ end
185
+
186
+ def foam_log_status(body)
187
+ status = Google::Rpc::Status.decode(body.to_s)
188
+ details = status.details.filter_map do |detail|
189
+ klass = ::Google::Protobuf::DescriptorPool.generated_pool.lookup(detail.type_name).msgclass
190
+ detail.unpack(klass) if klass
191
+ end
192
+ OpenTelemetry.handle_error(
193
+ message: "OTLP exporter received rpc.Status{message=#{status.message}, details=#{details}} for uri=#{@uri}"
194
+ )
195
+ rescue StandardError
196
+ # A body that isn't a valid rpc.Status (HTML error page from a
197
+ # proxy, empty 500…) still gets a readable failure line.
198
+ OpenTelemetry.handle_error(message: "OTLP exporter received an error response for uri=#{@uri}")
199
+ end
200
+
201
+ def foam_log_request_failure(response_code)
202
+ OpenTelemetry.handle_error(
203
+ message: "OTLP exporter received http.code=#{response_code} for uri='#{@uri}' in isolated send_bytes"
204
+ )
205
+ add_to_failure_counter(response_code.to_s)
206
+ end
207
+
208
+ def add_to_failure_counter(reason)
209
+ @metrics_reporter&.add_to_counter("otel.otlp_exporter.failure", labels: { "reason" => reason })
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
214
+ def report_value(metric, value)
215
+ @metrics_reporter&.record_value(metric, value: value)
216
+ rescue StandardError
217
+ nil
218
+ end
219
+ end
220
+
221
+ # The three per-signal exporters. Constructors, protobuf encoding,
222
+ # spec env-var handling, force_flush/shutdown all inherit from upstream;
223
+ # only the transport-touching privates are replaced by SendPath (module
224
+ # ancestry: subclass → SendPath → upstream class, so the upstream
225
+ # constructor's http_connection call dispatches into SendPath's).
226
+ class TraceExporter < ::OpenTelemetry::Exporter::OTLP::Exporter
227
+ include SendPath
228
+ end
229
+
230
+ class LogsExporter < ::OpenTelemetry::Exporter::OTLP::Logs::LogsExporter
231
+ include SendPath
232
+ end
233
+
234
+ class MetricsExporter < ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter
235
+ include SendPath
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,586 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The isolation-by-construction transport (export-isolation design, 2026-07-29;
4
+ # GOTCHAS F17). foam's delivery path — every byte foam sends on its own behalf —
5
+ # must never travel a client a third party can modify at runtime. Ruby's whole
6
+ # APM ecosystem prepends modules onto Net::HTTP ITSELF: every instance in the
7
+ # process dispatches through the prepend chain, so there is no such thing as an
8
+ # unwrapped Net::HTTP instance, and private helpers from different vendors
9
+ # collide on the class (the Datadog × OTel `annotate_span_with_response!` arity
10
+ # crash: 464 export crashes, zero rows landed, healthy-looking app). The
11
+ # suppression flag (Common::Utilities.untraced) does NOT protect the send —
12
+ # foreign vendors never check it; the upstream Ruby OTLP exporter died WITH
13
+ # untraced active, because the send itself travels the patched class.
14
+ #
15
+ # This client is the fix, modeled on the strongest industry mechanism (the Go
16
+ # OTLP exporter's owned transport; Sentry's native-fetch browser transport):
17
+ # a hand-written HTTP/1.1 sender over a TCPSocket / OpenSSL::SSL::SSLSocket
18
+ # that foam constructs and owns. Nothing here calls Net::HTTP, and nothing
19
+ # outside foam's exporters holds a reference — a foreign prepend chain on
20
+ # Net::HTTP structurally cannot sit in this path.
21
+ #
22
+ # Feature parity with the layer it replaces (non-negotiable — red-flags 1/4/5):
23
+ # * proxies: HTTP_PROXY / HTTPS_PROXY / NO_PROXY (+ lowercase), with CONNECT
24
+ # tunneling (and Proxy-Authorization) for https through a proxy — the
25
+ # enterprise egress pattern Net::HTTP handled implicitly;
26
+ # * TLS: VERIFY_PEER with HOSTNAME verification (context verify_hostname +
27
+ # post_connection_check), custom CA bundle, and client cert/key for mTLS —
28
+ # the OTEL_EXPORTER_OTLP_{CERTIFICATE,CLIENT_CERTIFICATE,CLIENT_KEY}
29
+ # options arrive here through the exporter constructors unchanged;
30
+ # * timeouts: connect/read/write deadlines via nonblocking IO + IO.select
31
+ # (one per-request deadline, matching the exporters' remaining-timeout
32
+ # accounting);
33
+ # * keep-alive with a single silent reconnect+retransmit on a stale pooled
34
+ # socket (EPIPE/ECONNRESET/EOF before any response byte), plus chunked and
35
+ # Connection: close response reading;
36
+ # * fork safety: the cached connection is keyed by Process.pid — a forked
37
+ # worker's first export discards the inherited socket and reconnects
38
+ # (rule 37; makes after_fork! free for the transport).
39
+ #
40
+ # What it deliberately does NOT do: capture. foam keeps instrumenting the
41
+ # app's Net::HTTP exactly as before — isolation applies to DELIVERY only.
42
+ require "openssl"
43
+ require "socket"
44
+ require "uri"
45
+
46
+ module Foam
47
+ module Otel
48
+ class IsolatedHttpClient
49
+ # Timeout/tunnel errors, foam-owned so the exporters' retry loop never
50
+ # rescues (or raises) a Net::* class.
51
+ class TimeoutError < StandardError; end
52
+ class OpenTimeoutError < TimeoutError; end
53
+ class ReadTimeoutError < TimeoutError; end
54
+ class WriteTimeoutError < TimeoutError; end
55
+ class ProxyConnectError < StandardError; end
56
+
57
+ # A plain value response — code (Integer), lowercased header Hash,
58
+ # body (String). Not a Net::HTTPResponse: response handling must not
59
+ # touch patchable Net::* classes either.
60
+ class Response
61
+ attr_reader :code, :headers, :body
62
+
63
+ def initialize(code, headers, body)
64
+ @code = code
65
+ @headers = headers
66
+ @body = body
67
+ end
68
+
69
+ def [](name) = @headers[name.to_s.downcase]
70
+ end
71
+
72
+ KEEP_ALIVE_TIMEOUT = 30 # seconds a pooled socket may idle, like Net::HTTP's
73
+ READ_CHUNK = 16 * 1024
74
+ # Response-size ceiling (fleet-review fix): OTLP success/error bodies
75
+ # are tiny (empty, or a small rpc.Status); a hostile or broken endpoint
76
+ # must not drive unbounded allocation in the app's process.
77
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
78
+ private_constant :READ_CHUNK
79
+
80
+ def initialize(uri, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, certificate_file: nil,
81
+ client_certificate_file: nil, client_key_file: nil,
82
+ keep_alive_timeout: KEEP_ALIVE_TIMEOUT)
83
+ @uri = uri
84
+ @host = uri.hostname
85
+ @port = uri.port
86
+ @use_ssl = uri.scheme == "https"
87
+ @ssl_verify_mode = ssl_verify_mode
88
+ @ssl_context = build_ssl_context(ssl_verify_mode, certificate_file,
89
+ client_certificate_file, client_key_file) if @use_ssl
90
+ @keep_alive_timeout = keep_alive_timeout
91
+ # Proxy config resolves ONCE at construction (Net::HTTP.new's :ENV
92
+ # behavior) — the exporter lives for the process, like its env config.
93
+ @proxy = resolve_proxy_from_env
94
+ @mutex = Mutex.new
95
+ @socket = nil
96
+ @pid = nil
97
+ @last_used = nil
98
+ @rbuf = +""
99
+ end
100
+
101
+ # Net::HTTP-facade introspection: the TLS pin audits (F-RB1 specs) read
102
+ # the constructed connection's verify mode; kept as real surface so the
103
+ # pin stays checkable on the isolated client.
104
+ attr_reader :ssl_verify_mode
105
+ alias verify_mode ssl_verify_mode
106
+
107
+ # Net::HTTP-facade lifecycle bits the exporters' shutdown path calls.
108
+ def started? = !@socket.nil?
109
+
110
+ def finish
111
+ @mutex.synchronize { close_socket }
112
+ nil
113
+ end
114
+
115
+ # POST `body` to `path` with `headers`, all within `timeout` seconds
116
+ # (connect + write + full response read share one deadline). Returns a
117
+ # Response; raises the foam-owned timeout/socket errors above plus
118
+ # OpenSSL::SSL::SSLError / SocketError / SystemCallError / EOFError for
119
+ # the exporters' retry loop to classify.
120
+ def post(path, body:, headers:, timeout:)
121
+ @mutex.synchronize do
122
+ deadline = monotonic_now + timeout
123
+ begin
124
+ reused = ensure_connection(deadline)
125
+ begin
126
+ write_request(path, body, headers, deadline)
127
+ read_response(deadline)
128
+ rescue EOFError, Errno::EPIPE, Errno::ECONNRESET
129
+ # Stale keep-alive socket (server closed between exports): one
130
+ # silent reconnect + retransmit, ONLY when the failed attempt
131
+ # rode a pooled connection. A fresh connection failing is a
132
+ # real error.
133
+ raise unless reused
134
+
135
+ close_socket
136
+ ensure_connection(deadline)
137
+ write_request(path, body, headers, deadline)
138
+ read_response(deadline)
139
+ end
140
+ rescue StandardError
141
+ # Fleet-review fix (2026-07-29): ANY transport error — timeout,
142
+ # SSL, syscall, malformed response — poisons the connection
143
+ # (half-written request, partial @rbuf). Net::HTTP closed on
144
+ # every transport error; so does this client. The next post
145
+ # dials fresh instead of desyncing on a dirty stream.
146
+ close_socket
147
+ raise
148
+ end
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+
156
+ # ---- connection management -------------------------------------------
157
+
158
+ # Returns true when an existing pooled socket was reused, false when a
159
+ # fresh connection was made.
160
+ def ensure_connection(deadline)
161
+ if @socket
162
+ if @pid != Process.pid
163
+ # Fork safety (rule 37): a pid change means this socket is the
164
+ # PARENT's. Close ONLY our copy of the fd — SSLSocket#close
165
+ # would SSL_shutdown and write a close_notify record into the
166
+ # TCP stream the parent is still using (fleet-review fix
167
+ # 2026-07-29: close_notify is application-visible bytes, not fd
168
+ # state).
169
+ discard_socket
170
+ elsif !keep_alive_expired?
171
+ @last_used = monotonic_now
172
+ return true
173
+ else
174
+ close_socket
175
+ end
176
+ end
177
+ connect(deadline)
178
+ false
179
+ end
180
+
181
+ # Drop the socket WITHOUT protocol goodbyes: closes the raw fd only
182
+ # (no SSL_shutdown), for fds shared with a parent process across fork.
183
+ def discard_socket
184
+ io = @socket.respond_to?(:to_io) ? @socket.to_io : @socket
185
+ io&.close
186
+ rescue StandardError
187
+ nil
188
+ ensure
189
+ @socket = nil
190
+ @rbuf = +""
191
+ end
192
+
193
+ def keep_alive_expired?
194
+ @last_used && (monotonic_now - @last_used) > @keep_alive_timeout
195
+ end
196
+
197
+ def connect(deadline)
198
+ tcp =
199
+ if @proxy
200
+ sock = open_tcp(@proxy.hostname, @proxy.port, deadline)
201
+ establish_connect_tunnel(sock, deadline) if @use_ssl
202
+ sock
203
+ else
204
+ open_tcp(@host, @port, deadline)
205
+ end
206
+
207
+ @socket =
208
+ if @use_ssl
209
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, @ssl_context)
210
+ ssl.hostname = @host # SNI
211
+ ssl.sync_close = true
212
+ ssl_connect(ssl, deadline)
213
+ # Hostname verification (red-flag 4, CWE-295): verify_hostname on
214
+ # the context covers the handshake; post_connection_check is the
215
+ # belt-and-braces explicit check Net::HTTP performed for us.
216
+ ssl.post_connection_check(@host) unless @ssl_verify_mode == OpenSSL::SSL::VERIFY_NONE
217
+ ssl
218
+ else
219
+ tcp
220
+ end
221
+ @pid = Process.pid
222
+ @last_used = monotonic_now
223
+ @rbuf = +""
224
+ rescue StandardError
225
+ begin
226
+ tcp&.close
227
+ rescue StandardError
228
+ nil
229
+ end
230
+ @socket = nil
231
+ raise
232
+ end
233
+
234
+ def open_tcp(host, port, deadline)
235
+ remaining = remaining_or_raise(deadline, OpenTimeoutError, "connect")
236
+ # resolv_timeout bounds the DNS lookup by the same deadline the TCP
237
+ # connect gets (fleet-review fix: a hung resolver must not stall the
238
+ # export past its budget).
239
+ sock = Socket.tcp(host, port, connect_timeout: remaining, resolv_timeout: remaining)
240
+ sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
241
+ sock
242
+ rescue Errno::ETIMEDOUT
243
+ raise OpenTimeoutError, "connect to #{host}:#{port} timed out"
244
+ end
245
+
246
+ # CONNECT tunnel for https-through-proxy (red-flag 1): speak plain HTTP
247
+ # to the proxy, then start TLS INSIDE the established tunnel.
248
+ def establish_connect_tunnel(sock, deadline)
249
+ authority = "#{bracketed_host}:#{@port}"
250
+ request = +"CONNECT #{authority} HTTP/1.1\r\nHost: #{authority}\r\n"
251
+ request << "Proxy-Authorization: Basic #{proxy_basic_credentials}\r\n" if @proxy.user
252
+ request << "\r\n"
253
+ write_all(sock, request.b, deadline)
254
+
255
+ status_line, headers = read_head(sock, deadline)
256
+ code = parse_status_code(status_line)
257
+ raise ProxyConnectError, "proxy CONNECT to #{authority} failed: #{status_line.strip}" unless (200..299).cover?(code)
258
+
259
+ # RFC 7231 §4.3.6: a 2xx CONNECT response has no body; any buffered
260
+ # bytes past the head belong to the TLS handshake. Nothing to drain.
261
+ headers
262
+ end
263
+
264
+ def ssl_connect(ssl, deadline)
265
+ loop do
266
+ case ssl.connect_nonblock(exception: false)
267
+ when :wait_readable then wait_io(ssl, :read, deadline)
268
+ when :wait_writable then wait_io(ssl, :write, deadline)
269
+ else return
270
+ end
271
+ end
272
+ end
273
+
274
+ def close_socket
275
+ @socket&.close
276
+ rescue StandardError
277
+ nil
278
+ ensure
279
+ @socket = nil
280
+ @rbuf = +""
281
+ end
282
+
283
+ # ---- TLS context --------------------------------------------------------
284
+
285
+ def build_ssl_context(verify_mode, certificate_file, client_certificate_file, client_key_file)
286
+ context = OpenSSL::SSL::SSLContext.new
287
+ context.verify_mode = verify_mode
288
+ context.verify_hostname = verify_mode != OpenSSL::SSL::VERIFY_NONE
289
+ if certificate_file
290
+ context.ca_file = certificate_file
291
+ else
292
+ store = OpenSSL::X509::Store.new
293
+ store.set_default_paths
294
+ context.cert_store = store
295
+ end
296
+ # mTLS (spec OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE / _CLIENT_KEY,
297
+ # threaded through the exporter constructors). PKey.read handles RSA
298
+ # and EC keys (upstream's PKey::RSA.new was RSA-only).
299
+ context.cert = OpenSSL::X509::Certificate.new(File.read(client_certificate_file)) if client_certificate_file
300
+ context.key = OpenSSL::PKey.read(File.read(client_key_file)) if client_key_file
301
+ context
302
+ end
303
+
304
+ # ---- proxy resolution ---------------------------------------------------
305
+
306
+ # HTTP_PROXY / HTTPS_PROXY / NO_PROXY (+ lowercase twins), the same env
307
+ # contract Net::HTTP honored implicitly via URI::Generic#find_proxy.
308
+ def resolve_proxy_from_env
309
+ raw = if @use_ssl
310
+ env_first("HTTPS_PROXY", "https_proxy")
311
+ else
312
+ # Uppercase HTTP_PROXY is deliberately honored here too: this
313
+ # client never runs under CGI (where REQUEST_METHOD makes
314
+ # HTTP_PROXY attacker-controlled — httpoxy), and the exporter
315
+ # is a background sender.
316
+ env_first("HTTP_PROXY", "http_proxy")
317
+ end
318
+ return nil if raw.nil? || raw.strip.empty?
319
+ return nil if no_proxy?(@host, @port)
320
+
321
+ proxy = URI(raw)
322
+ return nil if proxy.hostname.nil? || proxy.hostname.empty?
323
+
324
+ proxy
325
+ rescue URI::Error
326
+ nil
327
+ end
328
+
329
+ def env_first(*names)
330
+ names.each do |name|
331
+ value = ENV.fetch(name, nil)
332
+ return value if value && !value.empty?
333
+ end
334
+ nil
335
+ end
336
+
337
+ def no_proxy?(host, port)
338
+ raw = env_first("NO_PROXY", "no_proxy")
339
+ return false if raw.nil?
340
+
341
+ host = host.downcase
342
+ raw.split(",").any? do |entry|
343
+ entry = entry.strip.downcase
344
+ next false if entry.empty?
345
+ next true if entry == "*"
346
+
347
+ entry_host, entry_port = entry.split(":", 2)
348
+ entry_host = entry_host.delete_prefix(".")
349
+ next false if entry_port && entry_port.to_i != port
350
+
351
+ host == entry_host || host.end_with?(".#{entry_host}")
352
+ end
353
+ end
354
+
355
+ def unescape(component)
356
+ URI.decode_www_form_component(component)
357
+ rescue StandardError
358
+ component
359
+ end
360
+
361
+ def proxy_basic_credentials
362
+ ["#{unescape(@proxy.user)}:#{unescape(@proxy.password || '')}"].pack("m0")
363
+ end
364
+
365
+ # ---- request writing ----------------------------------------------------
366
+
367
+ def bracketed_host
368
+ @host.include?(":") ? "[#{@host}]" : @host # IPv6 literal
369
+ end
370
+
371
+ def host_header
372
+ default_port = @use_ssl ? 443 : 80
373
+ @port == default_port ? bracketed_host : "#{bracketed_host}:#{@port}"
374
+ end
375
+
376
+ def request_target(path)
377
+ # http through a proxy speaks absolute-form; https tunnels via CONNECT
378
+ # and http(s) direct speak origin-form.
379
+ return "http://#{host_header}#{path}" if @proxy && !@use_ssl
380
+
381
+ path
382
+ end
383
+
384
+ def write_request(path, body, headers, deadline)
385
+ head = +"POST #{request_target(path)} HTTP/1.1\r\n"
386
+ head << "Host: #{host_header}\r\n"
387
+ head << "Content-Length: #{body.bytesize}\r\n"
388
+ # Plain-HTTP proxying authenticates per request (fleet-review fix:
389
+ # Net::HTTP sent proxy_basic_auth on every non-CONNECT proxied
390
+ # request; without this an authenticated proxy 407s every export).
391
+ head << "Proxy-Authorization: Basic #{proxy_basic_credentials}\r\n" if @proxy && !@use_ssl && @proxy.user
392
+ headers.each do |key, value|
393
+ key = key.to_s
394
+ next if /\A(host|content-length|connection|transfer-encoding)\z/i.match?(key)
395
+
396
+ # Header-injection hardening: a header value can never smuggle CRLF
397
+ # into foam's own framing.
398
+ head << "#{key.gsub(/[\r\n]/, ' ')}: #{value.to_s.gsub(/[\r\n]/, ' ')}\r\n"
399
+ end
400
+ head << "\r\n"
401
+ write_all(@socket, head.b + body.b, deadline)
402
+ @last_used = monotonic_now
403
+ end
404
+
405
+ # ---- response reading -----------------------------------------------------
406
+
407
+ def read_response(deadline)
408
+ code = nil
409
+ headers = nil
410
+ # Skip 1xx informational responses (we never send Expect, but a proxy
411
+ # or server may still emit one).
412
+ loop do
413
+ status_line, headers = read_head(@socket, deadline)
414
+ code = parse_status_code(status_line)
415
+ break if code >= 200
416
+ end
417
+
418
+ body = read_body(code, headers, deadline)
419
+
420
+ if headers["connection"].to_s.downcase.include?("close")
421
+ close_socket
422
+ elsif !@rbuf.empty?
423
+ # Fleet-review fix (2026-07-29): bytes beyond the response framing
424
+ # mean the stream is desynced (a lying Content-Length, pipelined
425
+ # junk). Poisoned leftovers must never become the NEXT response's
426
+ # prefix — drop the connection; the next post dials fresh.
427
+ close_socket
428
+ else
429
+ @last_used = monotonic_now
430
+ end
431
+ Response.new(code, headers, body)
432
+ end
433
+
434
+ # Reads a status line + header block (shared by responses and the
435
+ # CONNECT tunnel reply). Returns [status_line, headers-hash(lowercased)].
436
+ def read_head(io, deadline)
437
+ head = buffered_read_until(io, "\r\n\r\n", deadline)
438
+ lines = head.split("\r\n")
439
+ status_line = lines.shift.to_s
440
+ headers = {}
441
+ lines.each do |line|
442
+ name, _, value = line.partition(":")
443
+ next if name.empty?
444
+
445
+ name = name.strip.downcase
446
+ value = value.strip
447
+ # Duplicate headers combine per RFC 9110 §5.2 (retry-after is
448
+ # single-valued in practice; combining keeps the reader total).
449
+ headers[name] = headers.key?(name) ? "#{headers[name]}, #{value}" : value
450
+ end
451
+ [status_line, headers]
452
+ end
453
+
454
+ def parse_status_code(status_line)
455
+ match = %r{\AHTTP/\d(?:\.\d)? (\d{3})}.match(status_line)
456
+ raise EOFError, "malformed HTTP status line from #{@host}: #{status_line.inspect[0, 80]}" unless match
457
+
458
+ match[1].to_i
459
+ end
460
+
461
+ def read_body(code, headers, deadline)
462
+ # RFC 9112 §6.3: 204 and 304 never carry a body, framing headers or
463
+ # not — without this guard a keep-alive "204 No Content\r\n\r\n"
464
+ # (nginx/ALB idiom) would fall into the read-to-EOF branch and stall
465
+ # every export until its deadline (fleet-review fix 2026-07-29).
466
+ return +"" if code == 204 || code == 304
467
+
468
+ if headers["transfer-encoding"].to_s.downcase.include?("chunked")
469
+ read_chunked_body(deadline)
470
+ elsif (length = headers["content-length"])
471
+ length = length.to_i
472
+ raise EOFError, "response Content-Length #{length} exceeds the #{MAX_RESPONSE_BYTES}-byte cap" if length > MAX_RESPONSE_BYTES
473
+
474
+ buffered_read_exact(@socket, length, deadline)
475
+ else
476
+ # No framing → body runs to EOF and the connection dies with it
477
+ # (Connection: close semantics / HTTP/1.0 servers).
478
+ body = +""
479
+ body << @rbuf
480
+ @rbuf = +""
481
+ while (chunk = read_more(@socket, deadline))
482
+ body << chunk
483
+ raise EOFError, "unframed response exceeded the #{MAX_RESPONSE_BYTES}-byte cap" if body.bytesize > MAX_RESPONSE_BYTES
484
+ end
485
+ close_socket
486
+ body
487
+ end
488
+ end
489
+
490
+ def read_chunked_body(deadline)
491
+ body = +""
492
+ loop do
493
+ size_line = buffered_read_until(@socket, "\r\n", deadline)
494
+ token = size_line.split(";").first.to_s.strip
495
+ # A malformed size token must be a LOUD protocol error — to_i(16)
496
+ # would silently parse garbage as 0 and truncate the body while
497
+ # leaving the stream desynced (fleet-review fix 2026-07-29).
498
+ raise EOFError, "malformed chunk-size line from #{@host}: #{size_line.inspect[0, 40]}" unless /\A[0-9a-fA-F]+\z/.match?(token)
499
+
500
+ size = token.to_i(16)
501
+ raise EOFError, "chunked response exceeded the #{MAX_RESPONSE_BYTES}-byte cap" if body.bytesize + size > MAX_RESPONSE_BYTES
502
+
503
+ if size.zero?
504
+ # Trailers (if any) run to the terminating blank line.
505
+ loop do
506
+ line = buffered_read_until(@socket, "\r\n", deadline)
507
+ break if line.empty?
508
+ end
509
+ break
510
+ end
511
+ body << buffered_read_exact(@socket, size, deadline)
512
+ buffered_read_until(@socket, "\r\n", deadline) # chunk terminator
513
+ end
514
+ body
515
+ end
516
+
517
+ # ---- buffered + nonblocking IO with one deadline ---------------------------
518
+
519
+ def buffered_read_until(io, delimiter, deadline)
520
+ until (index = @rbuf.index(delimiter))
521
+ chunk = read_more(io, deadline)
522
+ raise EOFError, "connection to #{@host} closed mid-response" if chunk.nil?
523
+
524
+ @rbuf << chunk
525
+ end
526
+ out = @rbuf.slice!(0, index + delimiter.bytesize)
527
+ out.delete_suffix(delimiter)
528
+ end
529
+
530
+ def buffered_read_exact(io, length, deadline)
531
+ while @rbuf.bytesize < length
532
+ chunk = read_more(io, deadline)
533
+ raise EOFError, "connection to #{@host} closed mid-body" if chunk.nil?
534
+
535
+ @rbuf << chunk
536
+ end
537
+ @rbuf.slice!(0, length)
538
+ end
539
+
540
+ # One nonblocking read; nil at EOF. IO::Wait* loops through the deadline
541
+ # (SSL reads can want WRITABLE during renegotiation and vice versa).
542
+ def read_more(io, deadline)
543
+ loop do
544
+ result = io.read_nonblock(READ_CHUNK, exception: false)
545
+ case result
546
+ when :wait_readable then wait_io(io, :read, deadline)
547
+ when :wait_writable then wait_io(io, :write, deadline)
548
+ when nil then return nil
549
+ else return result
550
+ end
551
+ end
552
+ end
553
+
554
+ def write_all(io, data, deadline)
555
+ offset = 0
556
+ while offset < data.bytesize
557
+ result = io.write_nonblock(data.byteslice(offset..), exception: false)
558
+ case result
559
+ when :wait_writable then wait_io(io, :write, deadline, kind: WriteTimeoutError)
560
+ when :wait_readable then wait_io(io, :read, deadline, kind: WriteTimeoutError)
561
+ else offset += result
562
+ end
563
+ end
564
+ end
565
+
566
+ def wait_io(io, direction, deadline, kind: ReadTimeoutError)
567
+ remaining = remaining_or_raise(deadline, kind, direction)
568
+ plain = io.respond_to?(:to_io) ? io.to_io : io
569
+ ready =
570
+ if direction == :read
571
+ IO.select([plain], nil, nil, remaining)
572
+ else
573
+ IO.select(nil, [plain], nil, remaining)
574
+ end
575
+ raise kind, "#{direction} on #{@host} timed out" unless ready
576
+ end
577
+
578
+ def remaining_or_raise(deadline, kind, what)
579
+ remaining = deadline - monotonic_now
580
+ raise kind, "#{what} deadline exceeded for #{@host}" if remaining <= 0
581
+
582
+ remaining
583
+ end
584
+ end
585
+ end
586
+ end
@@ -28,8 +28,15 @@ module Foam
28
28
  # carrying the fleet ingest token and the full OTLP payload. There is
29
29
  # deliberately NO foam option to weaken this; init warns when the env
30
30
  # var is set (env_vars posture, rule 15).
31
+ # Every exporter below is a Foam::Otel::IsolatedExporters subclass
32
+ # (export-isolation design, 2026-07-29): identical construction surface
33
+ # and retry semantics to the upstream OTLP exporters, but the bytes ride
34
+ # foam's owned IsolatedHttpClient — never Net::HTTP, which every foreign
35
+ # APM in the process is free to prepend onto (the Datadog
36
+ # auto_instrument outage class: 464 export crashes, zero rows landed,
37
+ # with the untraced suppression flag active the whole time).
31
38
  def setup_traces(endpoint, headers, resource, config, extra_processors)
32
- inner = OpenTelemetry::Exporter::OTLP::Exporter.new(
39
+ inner = IsolatedExporters::TraceExporter.new(
33
40
  endpoint: "#{endpoint}/v1/traces", headers: headers,
34
41
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
35
42
  )
@@ -88,7 +95,7 @@ module Foam
88
95
  end
89
96
 
90
97
  def setup_logs(endpoint, headers, resource, config, extra_processors)
91
- inner = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
98
+ inner = IsolatedExporters::LogsExporter.new(
92
99
  endpoint: "#{endpoint}/v1/logs", headers: headers,
93
100
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER # F-RB1 pin — see setup_traces
94
101
  )
@@ -149,7 +156,7 @@ module Foam
149
156
  # additional_metric_readers. The reader needs explicit after_fork
150
157
  # recovery (GOTCHAS R1) — tracked in @metric_readers.
151
158
  def setup_metrics(endpoint, headers, resource, config, extra_readers)
152
- inner = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
159
+ inner = IsolatedExporters::MetricsExporter.new(
153
160
  endpoint: "#{endpoint}/v1/metrics", headers: headers,
154
161
  ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER # F-RB1 pin — see setup_traces
155
162
  )
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The prepend-collision shield (coexistence mandate 2026-07-29, GOTCHAS F18).
4
+ #
5
+ # Ruby APM agents instrument shared clients by PREPENDING modules onto the
6
+ # class itself, and Ruby gives all of those modules ONE private-method
7
+ # namespace: when two vendors define a same-named private helper at different
8
+ # signatures, method lookup hands every caller the FRONTMOST definition — so
9
+ # one vendor's wrapper invokes the other vendor's helper and crashes with an
10
+ # ArgumentError. This is the exact live collision between a proprietary
11
+ # agent's Net::HTTP wrapper and the upstream OTel contrib net_http patch
12
+ # (`annotate_span_with_response!`, 3-arg vs 2-arg): every HTTP request the
13
+ # APP makes dies inside the chain. Foam ships that contrib patch for capture,
14
+ # so a foam + agent process contains the collision — and foam's contract is
15
+ # to COEXIST: the app must keep working with foam installed next to any
16
+ # agent.
17
+ #
18
+ # The shield is the structural fix, applied WITHOUT touching either vendor's
19
+ # code (renaming the upstream helper alone would break the upstream caller,
20
+ # which resolves the name through the same shared chain). At init, after
21
+ # foam's instrumentation sweep, the shield scans the prepend chain of the
22
+ # known collision-prone class (Net::HTTP) for helper names — private or
23
+ # public; the shim preserves the shielded definitions' visibility — defined by
24
+ # TWO OR MORE foreign modules at non-identical positional signatures, where
25
+ # the name does NOT exist on the class itself or behind it (those are the
26
+ # normal super-chains, not collisions). For each colliding name it prepends
27
+ # ONE frontmost dispatch method that routes each call to the implementation
28
+ # the caller intended:
29
+ # 1. by caller source: the implementation living in the same gem root as
30
+ # the calling frame wins (each vendor gets its own helper back);
31
+ # 2. else by signature: the unique implementation whose positional arity
32
+ # accepts the call;
33
+ # 3. else the previously-frontmost definition (status quo ante — the
34
+ # shield never makes an already-broken chain worse).
35
+ #
36
+ # No collision found → NOTHING is installed (the common, foam-only case is a
37
+ # scan and nothing else). Every shielded name is announced loudly (rule 15).
38
+ #
39
+ # Deliberate exception to foam's own-prepend hygiene rule: foam's other
40
+ # prepends carry zero private helpers precisely so foam can never collide;
41
+ # the shield's whole job is to OWN the already-colliding names, and its
42
+ # dispatch methods accept (*args, **kwargs, &block) so no caller can ever
43
+ # arity-crash against the shield itself.
44
+ #
45
+ # Residual (documented in GOTCHAS F18): an agent that patches AFTER foam's
46
+ # init lands ahead of the shield and re-exposes the raw collision until the
47
+ # next init/fork re-scan; agents overwhelmingly boot first (initializer /
48
+ # preload), which is the order the coexistence gate proves.
49
+ require_relative "diagnostics"
50
+
51
+ module Foam
52
+ module Otel
53
+ module PrependCollisionShield
54
+ class << self
55
+ # Scan + shield the class's prepend chain. Idempotent per collision
56
+ # set: names already shielded by a live foam shim are skipped.
57
+ # Never raises (rule 9): a scan failure degrades to a loud no-op.
58
+ def install!(klass)
59
+ front = klass.ancestors.take_while { |m| !m.equal?(klass) }
60
+ already = front.select { |m| shim?(m) }.flat_map { |m| m.instance_variable_get(:@foam_shielded_names) || [] }
61
+ foreign = front.reject { |m| shim?(m) }
62
+ collisions = colliding_helpers(klass, foreign).reject { |name, _| already.include?(name) }
63
+ return [] if collisions.empty?
64
+
65
+ klass.prepend(build_shim(collisions))
66
+ collisions.each do |name, defs|
67
+ owners = defs.map { |d| d[:mod].name || d[:mod].inspect }.join(" AND ")
68
+ Diagnostics.warn("coexistence shield: private helper ##{name} on #{klass} is defined at " \
69
+ "incompatible signatures by #{owners} — one would crash the other's callers " \
70
+ "(and every #{klass} user). Foam installed a signature-dispatch shim so both " \
71
+ "keep working (GOTCHAS F18).")
72
+ end
73
+ collisions.keys
74
+ rescue StandardError, SystemStackError => e
75
+ Diagnostics.warn("coexistence shield: scan of #{klass} failed (#{e.class}: #{e.message}) — " \
76
+ "no shim installed")
77
+ []
78
+ end
79
+
80
+ # The dispatch core, called from every shim method. Public for the
81
+ # shim modules only; not customer surface.
82
+ def dispatch(receiver, defs, caller_path, args, kwargs, block)
83
+ impl = resolve(defs, caller_path, args, kwargs)
84
+ if kwargs.empty?
85
+ impl.bind_call(receiver, *args, &block)
86
+ else
87
+ impl.bind_call(receiver, *args, **kwargs, &block)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def shim?(mod)
94
+ mod.instance_variable_defined?(:@foam_shielded_names)
95
+ end
96
+
97
+ # name => [{mod:, um:, min:, max:, kw:, root:}, ...] for every private
98
+ # helper defined by >= 2 foreign front modules at non-identical
99
+ # positional signatures, excluding names the class (or anything
100
+ # behind it) defines — those are super-chains, not collisions.
101
+ def colliding_helpers(klass, foreign)
102
+ behind = klass.ancestors.drop_while { |m| !m.equal?(klass) }
103
+ table = Hash.new { |h, k| h[k] = [] }
104
+ foreign.each do |mod|
105
+ (mod.instance_methods(false) + mod.private_instance_methods(false)).each do |name|
106
+ table[name] << describe(mod, name)
107
+ end
108
+ end
109
+ table.select do |name, defs|
110
+ defs.length >= 2 &&
111
+ behind.none? { |m| m.method_defined?(name, false) || m.private_method_defined?(name, false) } &&
112
+ defs.map { |d| [d[:min], d[:max]] }.uniq.length > 1
113
+ end
114
+ end
115
+
116
+ def describe(mod, name)
117
+ um = mod.instance_method(name)
118
+ min = 0
119
+ max = 0
120
+ kw = false
121
+ is_public = mod.instance_methods(false).include?(name)
122
+ um.parameters.each do |kind, _|
123
+ case kind
124
+ when :req
125
+ min += 1
126
+ max += 1 if max
127
+ when :opt
128
+ max += 1 if max
129
+ when :rest
130
+ max = nil
131
+ when :key, :keyreq, :keyrest
132
+ kw = true
133
+ end
134
+ end
135
+ # Gem root = everything before the LAST "/lib/" segment.
136
+ # rpartition, not split: on /var/lib/gems, /usr/lib/ruby and rbenv
137
+ # layouts the FIRST "/lib/" is the interpreter's, and splitting
138
+ # there collapses every gem to one shared root, disabling
139
+ # caller-source dispatch entirely (fleet-review fix 2026-07-29).
140
+ source = um.source_location&.first.to_s
141
+ root = source.include?("/lib/") ? source.rpartition("/lib/").first : nil
142
+ { mod: mod, um: um, min: min, max: max, kw: kw, public: is_public,
143
+ root: root.to_s.empty? ? nil : root }
144
+ end
145
+
146
+ def resolve(defs, caller_path, args, kwargs)
147
+ # 1. the caller's own gem gets its own helper back; several
148
+ # same-root candidates (or none) narrow the pool and fall through
149
+ # to the signature test.
150
+ pool = defs
151
+ if caller_path
152
+ own = defs.select { |d| d[:root] && caller_path.start_with?(d[:root]) }
153
+ return own.first[:um] if own.length == 1
154
+
155
+ pool = own unless own.empty?
156
+ end
157
+ # 2. signature match — first within the narrowed pool, then across
158
+ # every definition.
159
+ fitting = pool.select { |d| fits?(d, args, kwargs) }
160
+ fitting = defs.select { |d| fits?(d, args, kwargs) } if fitting.empty?
161
+ return fitting.first[:um] unless fitting.empty?
162
+
163
+ # 3. status quo ante: the frontmost definition (defs are collected
164
+ # in ancestry order), exactly what an unshielded chain would run.
165
+ defs.first[:um]
166
+ end
167
+
168
+ def fits?(definition, args, kwargs)
169
+ args.length >= definition[:min] &&
170
+ (definition[:max].nil? || args.length <= definition[:max]) &&
171
+ (kwargs.empty? || definition[:kw])
172
+ end
173
+
174
+ def build_shim(collisions)
175
+ shim = Module.new
176
+ shim.instance_variable_set(:@foam_shielded_names, collisions.keys.freeze)
177
+ collisions.each do |name, defs|
178
+ shim.define_method(name) do |*args, **kwargs, &block|
179
+ location = caller_locations(1, 1)&.first
180
+ PrependCollisionShield.dispatch(self, defs, location&.absolute_path || location&.path,
181
+ args, kwargs, block)
182
+ end
183
+ # Visibility follows the shielded definitions: privatizing a name
184
+ # some vendor exposes publicly would break that vendor's external
185
+ # callers with NoMethodError (fleet-review fix 2026-07-29).
186
+ shim.send(:private, name) unless defs.any? { |d| d[:public] }
187
+ end
188
+ shim
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -145,6 +145,36 @@ module Foam
145
145
  # service-container fallback, pg_isready gate, leak cleanup, numeric
146
146
  # version pick. Hooks run on the export thread — non-blocking only
147
147
  # (documented, GOTCHAS F16).
148
- VERSION = "1.8.1"
148
+ # 1.9.0: EXPORT ISOLATION (export-isolation design 2026-07-29,
149
+ # docs/decisions/export-isolation-ruby.md, GOTCHAS F17; MINOR —
150
+ # reliability-motivated transport change, zero API/config/wire-shape
151
+ # change). Foam's OTLP delivery no longer travels Net::HTTP — the class
152
+ # every co-resident APM prepends onto (the wrong-arity private-helper
153
+ # collision class: every export crashes, zero rows land, healthy app,
154
+ # WITH the untraced flag active). All six exporters (door 1 + door 2 ×
155
+ # traces/logs/metrics) now ride Foam::Otel::IsolatedHttpClient, an
156
+ # owned hand-written HTTP/1.1 sender: TLS VERIFY_PEER + hostname check,
157
+ # CA/mTLS options, HTTP(S)_PROXY/NO_PROXY with CONNECT tunneling,
158
+ # per-request deadlines, keep-alive with one stale-socket retransmit,
159
+ # chunked/Connection: close reading, pid-keyed fork-safe reconnect.
160
+ # Upstream retry semantics preserved verbatim (429/503 honor
161
+ # Retry-After, 408/502/504 + transient socket errors back off with
162
+ # jitter, other 4xx drop); spec OTEL_EXPORTER_OTLP_* env handling and
163
+ # the endpoint policy unchanged; untraced suppression kept as layer
164
+ # two. Capture untouched — foam still instruments the app's Net::HTTP
165
+ # exactly as before.
166
+ # ALSO (coexistence mandate, same release, GOTCHAS F18): the prepend-
167
+ # collision shield. Cross-vendor private-helper collisions on
168
+ # Net::HTTP (the live `annotate_span_with_response!` 3-arg vs 2-arg
169
+ # arity crash between a proprietary agent and the upstream contrib
170
+ # net_http patch foam ships) crash the APP's own HTTP calls; init now
171
+ # scans the prepend chain after the sweep and installs one frontmost
172
+ # signature-dispatch method per colliding name (caller-source gem
173
+ # first, then arity fit, else status quo ante) so every vendor's
174
+ # wrapper gets its own helper back. Collision-free chains (the common
175
+ # case) install NOTHING. Proven against the real agents in
176
+ # test-apps/ruby-coexistence (six vendors, both patch mechanisms,
177
+ # patched-client probe REQUIRED green).
178
+ VERSION = "1.9.0"
149
179
  end
150
180
  end
data/lib/foam/otel.rb CHANGED
@@ -28,4 +28,5 @@ require_relative "otel/redaction"
28
28
  require_relative "otel/errors"
29
29
  require_relative "otel/metrics"
30
30
  require_relative "otel/api" # customer-facing helpers (API gem only)
31
+ require_relative "otel/prepend_collision_shield" # coexistence shield (stdlib-only; F17)
31
32
  require_relative "otel/init" # init() + orchestration (SDK loaded lazily)
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.8.1
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
@@ -390,6 +390,8 @@ files:
390
390
  - lib/foam/otel/ingest_metric_reader.rb
391
391
  - lib/foam/otel/init.rb
392
392
  - lib/foam/otel/instance.rb
393
+ - lib/foam/otel/isolated_exporters.rb
394
+ - lib/foam/otel/isolated_http_client.rb
393
395
  - lib/foam/otel/llm.rb
394
396
  - lib/foam/otel/llm/anthropic_shim.rb
395
397
  - lib/foam/otel/llm/gemini_shim.rb
@@ -399,6 +401,7 @@ files:
399
401
  - lib/foam/otel/metrics.rb
400
402
  - lib/foam/otel/payload_capture.rb
401
403
  - lib/foam/otel/pipelines.rb
404
+ - lib/foam/otel/prepend_collision_shield.rb
402
405
  - lib/foam/otel/redacting_exporter.rb
403
406
  - lib/foam/otel/redaction.rb
404
407
  - lib/foam/otel/resource.rb