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 +4 -4
- data/GOTCHAS.md +126 -0
- data/lib/foam/otel/ingest.rb +6 -4
- data/lib/foam/otel/init.rb +10 -0
- data/lib/foam/otel/isolated_exporters.rb +239 -0
- data/lib/foam/otel/isolated_http_client.rb +586 -0
- data/lib/foam/otel/pipelines.rb +10 -3
- data/lib/foam/otel/prepend_collision_shield.rb +193 -0
- data/lib/foam/otel/version.rb +31 -1
- data/lib/foam/otel.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d27bb6b38712364503503fecd37ea383f9a7e3fb3648da8ef41524c575ef9302
|
|
4
|
+
data.tar.gz: 0c9fee962999043120904c8ffe85621e32ae04fcb300280ec8af0b2c77291a0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/foam/otel/ingest.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
+
IsolatedExporters::MetricsExporter.new(
|
|
305
307
|
endpoint: "#{endpoint}/v1/metrics", headers: headers,
|
|
306
308
|
ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
|
307
309
|
)
|
data/lib/foam/otel/init.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/pipelines.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/foam/otel/version.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|