foam-otel 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/GOTCHAS.md +187 -0
- data/README.md +35 -10
- data/lib/foam/otel/before_send.rb +128 -30
- data/lib/foam/otel/config.rb +48 -5
- 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 +52 -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
|
@@ -808,6 +808,193 @@ exfiltratable — the value-pattern secret layer is the required second control
|
|
|
808
808
|
|
|
809
809
|
---
|
|
810
810
|
|
|
811
|
+
## F16: before_send hooks run inside the export lock, and record bookkeeping must be re-normalized behind them
|
|
812
|
+
|
|
813
|
+
- **Trap**: Three traps share the seam. (1) The batch processors call
|
|
814
|
+
`exporter.export` inside `@export_mutex.synchronize` with no lock timeout —
|
|
815
|
+
a customer `before_send` hook that blocks (DB lookup, un-timed HTTP, sleep)
|
|
816
|
+
wedges the worker thread AND any concurrent `force_flush`/`shutdown`
|
|
817
|
+
acquiring the same mutex: the process can hang at exit until SIGKILL.
|
|
818
|
+
(2) The OTLP exporters encode `dropped_attributes_count` as
|
|
819
|
+
`total_recorded_attributes - attributes.size` into a protobuf **uint32** —
|
|
820
|
+
a hook that ADDS an attribute without the total being fixed up makes the
|
|
821
|
+
subtraction negative, protobuf raises `RangeError`, `encode` rescues to
|
|
822
|
+
nil, and the WHOLE export request — every healthy record batched beside
|
|
823
|
+
the touched one — is dropped, silently, forever (the same trap
|
|
824
|
+
session_stitching already documents for foam's own stamped attributes; a
|
|
825
|
+
hand-built replacement record with nil totals is the NoMethodError variant
|
|
826
|
+
of the same encode failure). (3) A hook fault marks the whole batch
|
|
827
|
+
FAILURE (redacting-exporter parity), and `force_flush` aborts its drain
|
|
828
|
+
loop on the first non-SUCCESS batch — so a hook that faults
|
|
829
|
+
DETERMINISTICALLY on some record shape makes the at-exit flush abandon
|
|
830
|
+
everything queued behind the first faulted batch.
|
|
831
|
+
- **Sources**:
|
|
832
|
+
- Installed source: `OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor`
|
|
833
|
+
`#export_batch` inside `@export_mutex.synchronize`
|
|
834
|
+
(opentelemetry-sdk `trace/export/batch_span_processor.rb:189`), same shape
|
|
835
|
+
in `logs/export/batch_log_record_processor.rb:186`; `force_flush`'s
|
|
836
|
+
`return result_code unless result_code == SUCCESS` drain-abort
|
|
837
|
+
(`batch_span_processor.rb:113-115`).
|
|
838
|
+
- Installed source: `dropped_attributes_count: span_data.total_recorded_attributes - span_data.attributes&.size.to_i`
|
|
839
|
+
(opentelemetry-exporter-otlp `exporter.rb:343`; logs twin
|
|
840
|
+
`logs_exporter.rb:311`); `encode` rescues `StandardError` → nil →
|
|
841
|
+
`send_bytes(nil)` returns FAILURE for the whole request.
|
|
842
|
+
- In-repo precedent: `lib/foam/otel/session_stitching.rb` bumps
|
|
843
|
+
`@total_recorded_attributes` when foam adds its own attribute, guarding
|
|
844
|
+
against exactly trap (2).
|
|
845
|
+
- **Decision & why**: Hooks stay ON the export thread (any queue/thread
|
|
846
|
+
indirection would break the drop-before-serialize guarantee and add an
|
|
847
|
+
unbounded buffer); foam re-normalizes `total_recorded_*` on every
|
|
848
|
+
surviving record after the pipeline (`BeforeSend.restore_counters!` —
|
|
849
|
+
size + SDK-dropped delta captured pre-hook, nil-safe), hands hooks COPIED
|
|
850
|
+
attribute values and log bodies so in-place mutation cannot bleed into
|
|
851
|
+
the live record or a tenant's buffered view, and keeps FAILURE accounting
|
|
852
|
+
for faulted records (parity with the redacting exporters) while warning
|
|
853
|
+
ONCE per batch with error classes only. The blocking-hook risk is
|
|
854
|
+
accepted and documented (README "The `before_send` hook") — it is the
|
|
855
|
+
customer's own code on their own pipeline, the same trust grade as a
|
|
856
|
+
tenant processor, and rule 49's DoS bounds govern foam's code, not the
|
|
857
|
+
customer's.
|
|
858
|
+
- **Mitigation**: README pins the contract: non-blocking hooks only; stamp
|
|
859
|
+
request/job-thread context onto attributes at capture; replace (never
|
|
860
|
+
mutate) events/links/status; deterministic-fault hooks are a customer bug
|
|
861
|
+
surfaced by the aggregated per-batch `[foam]` warning + batch FAILURE.
|
|
862
|
+
Boot-time arity validation rejects hooks that cannot take one positional
|
|
863
|
+
argument (the Sentry two-arg port) so the fault-every-record shape is
|
|
864
|
+
caught on the engineer's machine (rule 10).
|
|
865
|
+
- **Test**: `spec/before_send_spec.rb` ("re-normalizes total_recorded",
|
|
866
|
+
"REAL OTLP encode", "aggregates hook faults", "arity", "in-place value
|
|
867
|
+
mutation never bleeds"); `spec/before_send_champ_scenario_spec.rb` (the
|
|
868
|
+
export-thread / stamped-context proof).
|
|
869
|
+
## F17: There is no unwrapped Net::HTTP instance — foam's export must own its transport (export isolation, 2026-07-29)
|
|
870
|
+
|
|
871
|
+
- **Trap**: Ruby APM agents instrument by PREPENDING modules onto `Net::HTTP`
|
|
872
|
+
itself, so every instance in the process — including the one inside the
|
|
873
|
+
upstream OTLP exporter — dispatches through the foreign prepend chain, and
|
|
874
|
+
private helper names from different vendors resolve against each other on
|
|
875
|
+
the shared ancestry (the wrong-arity `annotate_span_with_response!`
|
|
876
|
+
collision class: every export crashes, zero rows land, the app looks
|
|
877
|
+
healthy). The upstream exporter's `untraced` suppression does NOT protect
|
|
878
|
+
the send — foreign agents never check OTel's flag; the exporter died with
|
|
879
|
+
it active. Reproduced at production parity against a real proprietary
|
|
880
|
+
auto-instrumenting agent (docs/decisions/export-isolation-ruby.md, with
|
|
881
|
+
before/after crash and rows-landed counts).
|
|
882
|
+
- **Sources**:
|
|
883
|
+
- Installed source — the vulnerable transport: `Net::HTTP.new` inside
|
|
884
|
+
`http_connection` and the `Net::HTTPResponse`-matching send loop
|
|
885
|
+
(opentelemetry-exporter-otlp `exporter.rb:129,150-258`; same shape in
|
|
886
|
+
-otlp-logs `logs_exporter.rb` and -otlp-metrics `metrics_exporter.rb` —
|
|
887
|
+
with drifting private `backoff?` signatures across the three gems).
|
|
888
|
+
- Design reference (strongest industry mechanism, isolation by
|
|
889
|
+
construction): the Go OTLP exporter's owned internal transport and its
|
|
890
|
+
documented warning against injecting an instrumented transport; the
|
|
891
|
+
error-tracking vendors' native-fetch browser transports.
|
|
892
|
+
- The prior collision incident class: the upstream contrib `net_http`
|
|
893
|
+
patch's private helper namespace
|
|
894
|
+
(opentelemetry-instrumentation-net_http `patches/stable/instrumentation.rb:81`).
|
|
895
|
+
- **Decision & why**: Isolation by construction, layered over suppression —
|
|
896
|
+
never suppression alone. `Foam::Otel::IsolatedHttpClient` is a hand-written
|
|
897
|
+
HTTP/1.1 sender over foam-owned `TCPSocket`/`OpenSSL::SSL::SSLSocket`
|
|
898
|
+
(VERIFY_PEER + hostname verification, CA/mTLS options, HTTP(S)_PROXY /
|
|
899
|
+
NO_PROXY with CONNECT tunneling, deadline IO, keep-alive with one
|
|
900
|
+
stale-socket retransmit, chunked/`Connection: close` reading, pid-keyed
|
|
901
|
+
reconnect for fork safety). `IsolatedExporters::{Trace,Logs,Metrics}Exporter`
|
|
902
|
+
subclass the upstream exporters, replacing exactly `http_connection` and
|
|
903
|
+
`send_bytes` (foam-owned retry loop, upstream semantics verbatim: 429/503
|
|
904
|
+
honor Retry-After, 408/502/504 + transient socket errors back off with
|
|
905
|
+
jitter, other 4xx drop; `untraced` kept as layer two). All six construction
|
|
906
|
+
sites (door 1 + door 2 × three signals) — the package's entire outbound
|
|
907
|
+
surface — build these subclasses. Capture is untouched; and foam's own
|
|
908
|
+
prepends (LLM shims, Logger bridge, fork hook) define ONLY the intercepted
|
|
909
|
+
public method, so foam can never be the colliding party on anyone's class.
|
|
910
|
+
- **Mitigation**: a foreign prepend chain on `Net::HTTP` structurally cannot
|
|
911
|
+
sit in foam's delivery path; a hostile wrong-arity prepend that crashes
|
|
912
|
+
every ordinary `Net::HTTP` caller leaves foam's rows landing.
|
|
913
|
+
- **Test**: `spec/export_isolation_spec.rb` (hostile prepend in a forked
|
|
914
|
+
child + spy-prepend never-dispatches proof, 503/429 Retry-After retries,
|
|
915
|
+
CONNECT-tunnel and absolute-form proxy paths, NO_PROXY, TLS wrong-hostname
|
|
916
|
+
rejection, chunked/keep-alive/stale-socket/deadline/fork-safety units, the
|
|
917
|
+
untraced layer-two pin, and the zero-collidable-private-helpers invariant);
|
|
918
|
+
`spec/export_isolation_hardening_spec.rb` (protocol edges: 1xx skip,
|
|
919
|
+
duplicate/trailered/extension-chunked responses, EOF-framed bodies,
|
|
920
|
+
keep-alive expiry, malformed status lines, IPv6 Host framing, 8-thread
|
|
921
|
+
concurrency; retry accounting with the backoff clock stubbed: exact
|
|
922
|
+
retry-cap exhaustion, Retry-After delta AND HTTP-date, 408/502/504,
|
|
923
|
+
redirects never followed, zero/mid-loop budget exhaustion; compression
|
|
924
|
+
none + its env var; loud non-protobuf 5xx handling; shutdown lifecycle;
|
|
925
|
+
mTLS e2e via the spec CERTIFICATE/CLIENT_CERTIFICATE/CLIENT_KEY env vars
|
|
926
|
+
incl. the rejected no-client-cert half; proxy credentials on CONNECT,
|
|
927
|
+
refused CONNECT, lowercase env twins, the NO_PROXY shape matrix; all
|
|
928
|
+
three signals through a full init, door-2 tap e2e, and a REAL fork);
|
|
929
|
+
`spec/ingest_spec.rb` (case 4 half 3: zero feedback spans with BOTH
|
|
930
|
+
cooperative layers defeated); `spec/transport_spec.rb` (the exact isolated
|
|
931
|
+
classes on all six sites); and the REAL-agent coexistence gate
|
|
932
|
+
`test-apps/ruby-coexistence/run-verify.sh` (two postures booting the
|
|
933
|
+
actual commercial agents in auto-instrument mode, hard-gated on the
|
|
934
|
+
vendor module being observed live ahead of Net::HTTP in the running
|
|
935
|
+
process's ancestor chain, verdict = protobuf-decoded rows on all three
|
|
936
|
+
signals with zero export errors).
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
940
|
+
## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29)
|
|
941
|
+
|
|
942
|
+
- **Trap**: Every module prepended onto a class shares ONE private-method
|
|
943
|
+
namespace. When two observability vendors prepend same-named private
|
|
944
|
+
helpers at different signatures onto Net::HTTP, method lookup hands EVERY
|
|
945
|
+
caller the frontmost definition — one vendor's wrapper invokes the other
|
|
946
|
+
vendor's helper and raises ArgumentError on EVERY Net::HTTP request the
|
|
947
|
+
app makes. This is live in the wild today: a proprietary agent's wrapper
|
|
948
|
+
and the upstream OTel contrib net_http patch both define
|
|
949
|
+
`annotate_span_with_response!` (3-arg vs 2-arg). Foam ships that contrib
|
|
950
|
+
patch for capture, so a foam + agent process CONTAINS the collision — and
|
|
951
|
+
foam's promise is coexistence: the app must keep working.
|
|
952
|
+
- **Sources**:
|
|
953
|
+
- Installed source — the two colliding definitions:
|
|
954
|
+
opentelemetry-instrumentation-net_http 0.29.0
|
|
955
|
+
`patches/stable/instrumentation.rb:81` (`annotate_span_with_response!(span, response)`)
|
|
956
|
+
and the proprietary agent's contrib HTTP instrumentation calling its own
|
|
957
|
+
3-arg spelling of the same private name (verified live in the
|
|
958
|
+
coexistence gate; backtrace pinned in
|
|
959
|
+
docs/decisions/export-isolation-ruby.md §A).
|
|
960
|
+
- Ruby semantics: prepended modules join the class's ancestor chain and
|
|
961
|
+
private methods resolve through the SAME chain for every caller — there
|
|
962
|
+
is no per-module helper namespace.
|
|
963
|
+
- **Decision & why**: foam cannot rename the upstream helper (its own caller
|
|
964
|
+
resolves the name through the same shared chain — a rename breaks the
|
|
965
|
+
gem being "fixed"), and foam must never edit another vendor's module. So
|
|
966
|
+
foam owns the collision instead: after the instrumentation sweep, init
|
|
967
|
+
scans Net::HTTP's prepend chain for private-helper names defined by two
|
|
968
|
+
or more foreign modules at NON-identical positional signatures (names the
|
|
969
|
+
class itself defines are super-chains, never shielded) and prepends ONE
|
|
970
|
+
frontmost dispatch method per colliding name — routing by caller source
|
|
971
|
+
gem first, then by signature fit, falling back to the previously-frontmost
|
|
972
|
+
definition (status quo ante; the shield never makes a broken chain
|
|
973
|
+
worse). Its dispatch methods accept (*args, **kwargs, &block), so nothing
|
|
974
|
+
can arity-crash against the shield itself. No collision → NOTHING is
|
|
975
|
+
installed. Every shielded name is announced loudly (rule 15). This is the
|
|
976
|
+
ONE deliberate exception to foam's own-prepend hygiene rule (F17's
|
|
977
|
+
"foam's prepends carry zero private helpers"): the shield's whole job is
|
|
978
|
+
to own the already-colliding names.
|
|
979
|
+
- **Mitigation**: with the shield installed the coexistence gate's
|
|
980
|
+
patched-client probe went from 3/3 crashes to 3/3 passes under the real
|
|
981
|
+
agent, with both vendors' helpers verifiably executing again.
|
|
982
|
+
- **Residual**: an agent that patches AFTER foam's init lands ahead of the
|
|
983
|
+
shield and re-exposes the raw collision until a later init/fork re-scan;
|
|
984
|
+
agents overwhelmingly boot first (initializer/preload) — the order the
|
|
985
|
+
coexistence gate proves. Identical-signature collisions are left alone
|
|
986
|
+
(no crash class; dispatch could not disambiguate semantics).
|
|
987
|
+
- **Test**: `spec/prepend_collision_shield_spec.rb` (the unshielded RED
|
|
988
|
+
control, both-vendors-work dispatch, idempotence, no-op purity on
|
|
989
|
+
collision-free chains, super-chain and identical-signature exclusions,
|
|
990
|
+
fall-through to the real error, rule-9 scan degradation, caller-source
|
|
991
|
+
dispatch across two on-disk gem roots, and the real Net::HTTP + real
|
|
992
|
+
contrib patch field scenario red→green in a forked child);
|
|
993
|
+
`test-apps/ruby-coexistence/run-verify.sh` (the patched-client probe is a
|
|
994
|
+
REQUIRED pass on every vendor posture).
|
|
995
|
+
|
|
996
|
+
---
|
|
997
|
+
|
|
811
998
|
## General gotchas (applicable to Ruby)
|
|
812
999
|
|
|
813
1000
|
- **G1 — init after target import / pre-init no-op**: the API's proxy providers
|
data/README.md
CHANGED
|
@@ -331,7 +331,7 @@ not captured — inbound only.
|
|
|
331
331
|
| `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
|
|
332
332
|
| `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
|
|
333
333
|
| `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
|
|
334
|
-
| `before_send:` | Proc/Array | no | `[]` | Export hook (1.8.0): one callable — or an Array run as a pipeline — called with every **span and log record** at the export boundary, after batching and BEFORE foam's redaction pass and serialization, so nothing a hook drops ever leaves the process. Return the record to export it
|
|
334
|
+
| `before_send:` | Proc/Array | no | `[]` | Export hook (1.8.0): one callable — or an Array run as a pipeline — called with every **span and log record** at the export boundary, after batching and BEFORE foam's redaction pass and serialization, so nothing a hook drops ever leaves the process. Return the record (or a same-type replacement) to export it — in-place mutation supported; return `nil` to drop it; a raise or foreign return drops **that record** loudly, fail-closed, never a raise into your threads. Runs BEFORE redaction — a hook can never widen what ships. Spans and logs only; door 1 only. A non-callable (or a one-arg-incompatible lambda) raises at boot. Process-global. Hooks run on the export thread — keep them non-blocking. Full contract: "The `before_send` hook" below. |
|
|
335
335
|
| `capture_payloads:` | Symbol/String | no | `:off` | Inbound HTTP **body** capture on the rack server span (see "Payload (body) capture"). `:off` = zero teeing, zero middleware (the default); `:errors` = tee per request, attach only on exception/5xx; `:always` = attach on every request. Equivalent strings accepted; anything else raises at boot. Overridden in both directions by the `FOAM_CAPTURE_PAYLOADS` env clamp (env table below). Textual payloads only, 8192-char cap + `…[truncated]`, true `.size` attributes; sizes-only for binary/compressed. |
|
|
336
336
|
| `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
|
|
337
337
|
| `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
|
|
@@ -661,17 +661,42 @@ set.
|
|
|
661
661
|
|
|
662
662
|
The per-record contract (Sentry `beforeSend` semantics):
|
|
663
663
|
|
|
664
|
-
* **return the record
|
|
665
|
-
Mutating it in place is supported —
|
|
666
|
-
|
|
667
|
-
`record.attributes.delete("k")
|
|
664
|
+
* **return the record — or a replacement of the same type** → it continues
|
|
665
|
+
to the next hook / to export. Mutating it in place is supported —
|
|
666
|
+
`record.attributes` arrives as your own unfrozen copy (values included),
|
|
667
|
+
so `record.attributes["k"] = v`, `record.attributes.delete("k")`,
|
|
668
|
+
`record.attributes["k"].gsub!(...)` and (for logs) editing `record.body`
|
|
669
|
+
all just work without bleeding into the span your app still holds or the
|
|
670
|
+
view a tenant processor saw. Events, links and status are the exception:
|
|
671
|
+
**replace them wholesale** (`record.events = [...]`) — never mutate them
|
|
672
|
+
in place, they are shared structures. Foam re-normalizes the record's
|
|
673
|
+
`total_recorded_*` bookkeeping after your hooks run, so adding/deleting
|
|
674
|
+
attributes never corrupts the wire's dropped-count accounting.
|
|
668
675
|
* **return `nil`** → the record is dropped, silently (that is the filter
|
|
669
676
|
mechanism, not an error).
|
|
670
|
-
* **raise, or return
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
677
|
+
* **raise, or return a foreign object** → that record is dropped
|
|
678
|
+
**loudly** — one aggregated `[foam]` warning per batch, error classes
|
|
679
|
+
only (messages can carry your data) — and the batch reports failure:
|
|
680
|
+
fail-closed, a faulted hook never ships a record you may have meant to
|
|
681
|
+
scrub, and never raises into your application or export threads. Healthy
|
|
682
|
+
records in the same batch still export. Because a fault marks the whole
|
|
683
|
+
batch failed, a hook that faults **deterministically** also makes the
|
|
684
|
+
at-exit flush report failure — fix faulting hooks promptly (GOTCHAS F16).
|
|
685
|
+
|
|
686
|
+
**Hooks run on foam's export thread, inside the batch processor's export
|
|
687
|
+
lock.** Keep them fast and non-blocking: no network calls, no DB lookups,
|
|
688
|
+
no sleeping — a blocked hook stalls the export pipeline and can wedge the
|
|
689
|
+
at-exit flush. Anything a hook needs from the request/job thread
|
|
690
|
+
(tenant context, user identity) must be stamped onto the record as an
|
|
691
|
+
attribute at capture time (see the `additional_span_processors` stamper
|
|
692
|
+
pattern); thread-locals are gone by the time the hook runs.
|
|
693
|
+
|
|
694
|
+
Boot validation is strict (rule 10): a non-callable — or a **lambda/Method
|
|
695
|
+
whose signature cannot accept one positional argument** (e.g. a Sentry-port
|
|
696
|
+
`->(event, hint) { ... }` with two required params, or a required keyword)
|
|
697
|
+
— raises `ArgumentError` at `init`, because it would otherwise fault on
|
|
698
|
+
every record and ship zero telemetry from a green boot. Make the extra
|
|
699
|
+
Sentry `hint` param optional (`->(event, hint = nil)`) and it is accepted.
|
|
675
700
|
|
|
676
701
|
Scope: **spans and log records** (the two record-shaped signals — the hook
|
|
677
702
|
receives `SpanData` or `LogRecordData`; distinguish them with `is_a?` or
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "constants"
|
|
3
4
|
require_relative "diagnostics"
|
|
4
5
|
|
|
5
6
|
module Foam
|
|
@@ -53,37 +54,134 @@ module Foam
|
|
|
53
54
|
|
|
54
55
|
# Run the hook pipeline over one record: each hook receives the
|
|
55
56
|
# previous hook's return. Returns the surviving record, nil for an
|
|
56
|
-
# intentional drop, or ERROR for a faulted one.
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
# intentional drop, or ERROR for a faulted one. Fault DETAIL is
|
|
58
|
+
# appended to `faults` (error class / return class only — never a
|
|
59
|
+
# message, which can embed record values) so the exporter can warn
|
|
60
|
+
# ONCE per batch instead of once per record (the redacting exporters'
|
|
61
|
+
# aggregation convention; an unthrottled per-record warn is a stderr
|
|
62
|
+
# flood an adversary-shaped record stream controls — rule 15's
|
|
63
|
+
# "loud but never a flood" posture).
|
|
64
|
+
def run(hooks, record, signal, faults)
|
|
65
|
+
current, counters = thaw(record)
|
|
59
66
|
hooks.each do |hook|
|
|
60
67
|
result = hook.call(current)
|
|
61
68
|
return nil if result.nil?
|
|
62
69
|
|
|
63
70
|
unless result.is_a?(record.class)
|
|
64
|
-
|
|
65
|
-
"dropped (return the record, a replacement of the same type, or nil to drop; " \
|
|
66
|
-
"fail-closed, rule 14)")
|
|
71
|
+
faults << "hook returned #{result.class}"
|
|
67
72
|
return ERROR
|
|
68
73
|
end
|
|
69
74
|
current = result
|
|
70
75
|
end
|
|
76
|
+
restore_counters!(current, counters)
|
|
71
77
|
current
|
|
72
78
|
rescue StandardError, SystemStackError => e
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
faults << e.class.name
|
|
80
|
+
# Full detail (message included) only under diagnostics: true —
|
|
81
|
+
# e.message can carry attribute values.
|
|
82
|
+
Diagnostics.info("before_send fault detail (#{signal}): #{e.class}: #{e.message}")
|
|
75
83
|
ERROR
|
|
76
84
|
end
|
|
77
85
|
|
|
78
|
-
# A
|
|
79
|
-
#
|
|
86
|
+
# A dup the hook can safely mutate IN PLACE: the struct and the
|
|
87
|
+
# attributes hash are copied, and so are the mutable attribute VALUES
|
|
88
|
+
# (strings, and strings inside string arrays — the OTel attribute
|
|
89
|
+
# types) plus a structured log body — so `record.attributes["k"] = v`,
|
|
90
|
+
# `attributes["k"].gsub!(...)` and `record.body["k"] = v` never bleed
|
|
91
|
+
# into the live span/record the customer still holds or the masked
|
|
92
|
+
# view a tenant processor buffered (pipelines.rb), and never hit a
|
|
93
|
+
# frozen value. Events/links/status are NOT deep-copied: replace them
|
|
94
|
+
# wholesale (`record.events = [...]`), never mutate them in place —
|
|
95
|
+
# documented in the README.
|
|
96
|
+
#
|
|
97
|
+
# Also returns the record's SDK-drop counters, captured BEFORE the
|
|
98
|
+
# hooks run: the OTLP exporters encode dropped_attributes_count as
|
|
99
|
+
# `total_recorded_attributes - attributes.size` (a protobuf uint32),
|
|
100
|
+
# so a hook that ADDS an attribute without the total being fixed up
|
|
101
|
+
# afterwards makes the count NEGATIVE and the encoder drops the WHOLE
|
|
102
|
+
# export request — every healthy record in the batch, silently (the
|
|
103
|
+
# same trap session_stitching.rb documents and guards for foam's own
|
|
104
|
+
# added attributes). restore_counters! below re-normalizes the
|
|
105
|
+
# survivor from these.
|
|
80
106
|
def thaw(record)
|
|
81
107
|
copy = record.dup
|
|
82
108
|
if copy.respond_to?(:attributes) && copy.respond_to?(:attributes=)
|
|
83
109
|
attributes = copy.attributes
|
|
84
|
-
copy.attributes = attributes.nil? ? {} : attributes.
|
|
110
|
+
copy.attributes = attributes.nil? ? {} : attributes.transform_values { |v| thaw_value(v) }
|
|
85
111
|
end
|
|
86
|
-
copy
|
|
112
|
+
copy.body = thaw_body(copy.body) if copy.respond_to?(:body) && copy.respond_to?(:body=)
|
|
113
|
+
[copy, sdk_drop_counters(record)]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def thaw_value(value)
|
|
117
|
+
case value
|
|
118
|
+
when String then value.dup
|
|
119
|
+
when Array then value.map { |e| e.is_a?(String) ? e.dup : e }
|
|
120
|
+
else value
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Bounded deep copy of a log body (String / Hash / Array / scalar) so
|
|
125
|
+
# in-place body mutation is safe too; the depth guard mirrors the
|
|
126
|
+
# redaction engine's (rule 9 — an adversarial structure never drives
|
|
127
|
+
# a stack overflow).
|
|
128
|
+
def thaw_body(body, depth = 0)
|
|
129
|
+
return body if depth > MAX_REDACT_DEPTH
|
|
130
|
+
|
|
131
|
+
case body
|
|
132
|
+
when String then body.dup
|
|
133
|
+
when Hash then body.each_with_object({}) { |(k, v), out| out[k] = thaw_body(v, depth + 1) }
|
|
134
|
+
when Array then body.map { |v| thaw_body(v, depth + 1) }
|
|
135
|
+
else body
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# How many attributes/events/links the SDK itself dropped (limits)
|
|
140
|
+
# BEFORE the hooks ran — nil-safe, clamped at zero.
|
|
141
|
+
def sdk_drop_counters(record)
|
|
142
|
+
{
|
|
143
|
+
attributes: sdk_dropped(record, :total_recorded_attributes, :attributes),
|
|
144
|
+
events: sdk_dropped(record, :total_recorded_events, :events),
|
|
145
|
+
links: sdk_dropped(record, :total_recorded_links, :links),
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sdk_dropped(record, total_getter, list_getter)
|
|
150
|
+
return 0 unless record.respond_to?(total_getter)
|
|
151
|
+
|
|
152
|
+
total = record.public_send(total_getter).to_i
|
|
153
|
+
size = collection_size(record, list_getter)
|
|
154
|
+
total > size ? total - size : 0
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def collection_size(record, list_getter)
|
|
158
|
+
return 0 unless record.respond_to?(list_getter)
|
|
159
|
+
|
|
160
|
+
list = record.public_send(list_getter)
|
|
161
|
+
list.respond_to?(:size) ? list.size.to_i : 0
|
|
162
|
+
rescue StandardError
|
|
163
|
+
0
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Re-normalize the survivor's total_recorded_* bookkeeping after the
|
|
167
|
+
# hooks ran: total = what the record NOW carries + what the SDK had
|
|
168
|
+
# already dropped. Without this, a hook-ADDED attribute makes the
|
|
169
|
+
# OTLP encoder's `total - size` subtraction negative (protobuf uint32
|
|
170
|
+
# → RangeError → the encoder returns nil → the ENTIRE batch is
|
|
171
|
+
# dropped, silently, forever), and a hand-built replacement record
|
|
172
|
+
# with nil totals raises NoMethodError at the same line. Also keeps a
|
|
173
|
+
# hook DELETE honest (the wire no longer claims an SDK-limit drop).
|
|
174
|
+
def restore_counters!(record, counters)
|
|
175
|
+
if record.respond_to?(:total_recorded_attributes=)
|
|
176
|
+
record.total_recorded_attributes = collection_size(record, :attributes) + counters[:attributes]
|
|
177
|
+
end
|
|
178
|
+
if record.respond_to?(:total_recorded_events=)
|
|
179
|
+
record.total_recorded_events = collection_size(record, :events) + counters[:events]
|
|
180
|
+
end
|
|
181
|
+
if record.respond_to?(:total_recorded_links=)
|
|
182
|
+
record.total_recorded_links = collection_size(record, :links) + counters[:links]
|
|
183
|
+
end
|
|
184
|
+
record
|
|
87
185
|
end
|
|
88
186
|
|
|
89
187
|
# The span-side wrapper (composition over the redacting exporter, same
|
|
@@ -97,18 +195,19 @@ module Foam
|
|
|
97
195
|
|
|
98
196
|
def export(span_data, timeout: nil)
|
|
99
197
|
batch = span_data.to_a
|
|
100
|
-
|
|
198
|
+
faults = []
|
|
101
199
|
kept = batch.filter_map do |sd|
|
|
102
|
-
result = BeforeSend.run(@hooks, sd, :span)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
200
|
+
result = BeforeSend.run(@hooks, sd, :span, faults)
|
|
201
|
+
result.equal?(ERROR) ? nil : result
|
|
202
|
+
end
|
|
203
|
+
# ONE aggregated warn per batch (the redacting exporters'
|
|
204
|
+
# convention) — class names only, never messages/values.
|
|
205
|
+
unless faults.empty?
|
|
206
|
+
Diagnostics.warn("before_send faulted for #{faults.length} span record(s) — dropped, never " \
|
|
207
|
+
"exported half-transformed (fail-closed, rule 14): #{faults.uniq.take(3).join(', ')}")
|
|
109
208
|
end
|
|
110
209
|
status = kept.empty? ? OpenTelemetry::SDK::Trace::Export::SUCCESS : @inner.export(kept, timeout: timeout)
|
|
111
|
-
|
|
210
|
+
faults.empty? ? status : OpenTelemetry::SDK::Trace::Export::FAILURE
|
|
112
211
|
rescue StandardError, SystemStackError
|
|
113
212
|
# Fail closed past the per-record guards, loudly (rule 15) —
|
|
114
213
|
# never raise into the batch thread.
|
|
@@ -128,18 +227,17 @@ module Foam
|
|
|
128
227
|
|
|
129
228
|
def export(log_record_data, timeout: nil)
|
|
130
229
|
batch = log_record_data.to_a
|
|
131
|
-
|
|
230
|
+
faults = []
|
|
132
231
|
kept = batch.filter_map do |lrd|
|
|
133
|
-
result = BeforeSend.run(@hooks, lrd, :log)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
end
|
|
232
|
+
result = BeforeSend.run(@hooks, lrd, :log, faults)
|
|
233
|
+
result.equal?(ERROR) ? nil : result
|
|
234
|
+
end
|
|
235
|
+
unless faults.empty?
|
|
236
|
+
Diagnostics.warn("before_send faulted for #{faults.length} log record(s) — dropped, never " \
|
|
237
|
+
"exported half-transformed (fail-closed, rule 14): #{faults.uniq.take(3).join(', ')}")
|
|
140
238
|
end
|
|
141
239
|
status = kept.empty? ? OpenTelemetry::SDK::Logs::Export::SUCCESS : @inner.export(kept, timeout: timeout)
|
|
142
|
-
|
|
240
|
+
faults.empty? ? status : OpenTelemetry::SDK::Logs::Export::FAILURE
|
|
143
241
|
rescue StandardError, SystemStackError
|
|
144
242
|
Diagnostics.warn("before_send pass raised past its guards — log batch dropped (fail-closed, rule 14/15)")
|
|
145
243
|
OpenTelemetry::SDK::Logs::Export::FAILURE
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -108,22 +108,65 @@ module Foam
|
|
|
108
108
|
# #call — a lambda, a Proc, a Method, or any hook object) or an Array
|
|
109
109
|
# of callables run as a pipeline. Anything else — including a nil or
|
|
110
110
|
# non-callable INSIDE the array — raises here, never a silent no-op
|
|
111
|
-
# that leaves the customer believing their filter runs.
|
|
111
|
+
# that leaves the customer believing their filter runs. The array is
|
|
112
|
+
# COPIED before freezing (never freeze the caller's own object).
|
|
113
|
+
#
|
|
114
|
+
# Arity is validated too, where Ruby lets us introspect it: a lambda
|
|
115
|
+
# or Method that cannot accept ONE positional argument (a Sentry-port
|
|
116
|
+
# `->(event, hint) { }`, a forgotten-param `-> { }`, a required
|
|
117
|
+
# keyword) would pass a bare respond_to?(:call) check and then fault
|
|
118
|
+
# on EVERY record at export — a green boot that ships zero telemetry
|
|
119
|
+
# forever. That is exactly the boot-detectable programming error rule
|
|
120
|
+
# 10 exists for. Non-lambda Procs are arity-forgiving by language
|
|
121
|
+
# semantics and uninspectable callables get the benefit of the doubt.
|
|
112
122
|
def validate_before_send!(value, context: "Foam::Otel.init")
|
|
113
123
|
hooks = if value.nil?
|
|
114
124
|
[]
|
|
115
125
|
else
|
|
116
|
-
value.is_a?(Array) ? value : [value]
|
|
126
|
+
value.is_a?(Array) ? value.dup : [value]
|
|
117
127
|
end
|
|
118
128
|
hooks.each_with_index do |hook, index|
|
|
119
|
-
|
|
129
|
+
unless hook.respond_to?(:call)
|
|
130
|
+
raise ArgumentError, "#{context} before_send: must be a callable (responds to #call) or an " \
|
|
131
|
+
"Array of callables, got #{hook.class} at position #{index}"
|
|
132
|
+
end
|
|
133
|
+
next if before_send_arity_ok?(hook)
|
|
120
134
|
|
|
121
|
-
raise ArgumentError, "#{context} before_send:
|
|
122
|
-
"
|
|
135
|
+
raise ArgumentError, "#{context} before_send: the callable at position #{index} cannot be " \
|
|
136
|
+
"called with one record argument (it takes a different number of " \
|
|
137
|
+
"required positional parameters, or requires keywords) — before_send " \
|
|
138
|
+
"hooks are called as hook.call(record)"
|
|
123
139
|
end
|
|
124
140
|
hooks.freeze
|
|
125
141
|
end
|
|
126
142
|
|
|
143
|
+
# True when the hook can be invoked as hook.call(record). Lambdas and
|
|
144
|
+
# Methods enforce their signatures, so they are checked; non-lambda
|
|
145
|
+
# Procs coerce arguments (always fine); anything whose #call cannot
|
|
146
|
+
# be introspected passes (never a false boot failure).
|
|
147
|
+
def before_send_arity_ok?(hook)
|
|
148
|
+
params = if hook.is_a?(Proc)
|
|
149
|
+
return true unless hook.lambda?
|
|
150
|
+
|
|
151
|
+
hook.parameters
|
|
152
|
+
elsif hook.is_a?(Method) || hook.is_a?(UnboundMethod)
|
|
153
|
+
hook.parameters
|
|
154
|
+
else
|
|
155
|
+
begin
|
|
156
|
+
hook.method(:call).parameters
|
|
157
|
+
rescue StandardError
|
|
158
|
+
return true
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
return false if params.any? { |kind, _| kind == :keyreq }
|
|
162
|
+
|
|
163
|
+
required = params.count { |kind, _| kind == :req }
|
|
164
|
+
flexible = params.any? { |kind, _| kind == :opt || kind == :rest }
|
|
165
|
+
required == 1 || (required.zero? && flexible)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
127
170
|
# ---- the capture_payloads option (payload-capture mandate 2026-07-28)
|
|
128
171
|
# Canonicalize a mode value: the three symbols, or their string
|
|
129
172
|
# spellings trimmed + case-insensitive, map to the canonical symbol;
|
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
|