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