Module: Foam::Otel

Defined in:
lib/foam/otel/api.rb,
lib/foam/otel/llm.rb,
lib/foam/otel/init.rb,
lib/foam/otel/config.rb,
lib/foam/otel/errors.rb,
lib/foam/otel/ingest.rb,
lib/foam/otel/metrics.rb,
lib/foam/otel/version.rb,
lib/foam/otel/instance.rb,
lib/foam/otel/resource.rb,
lib/foam/otel/constants.rb,
lib/foam/otel/pipelines.rb,
lib/foam/otel/redaction.rb,
lib/foam/otel/classifier.rb,
lib/foam/otel/fork_hooks.rb,
lib/foam/otel/before_send.rb,
lib/foam/otel/diagnostics.rb,
lib/foam/otel/logger_bridge.rb,
lib/foam/otel/header_capture.rb,
lib/foam/otel/llm/gemini_shim.rb,
lib/foam/otel/llm/openai_shim.rb,
lib/foam/otel/payload_capture.rb,
lib/foam/otel/runtime_metrics.rb,
lib/foam/otel/llm/ruby_llm_shim.rb,
lib/foam/otel/session_stitching.rb,
lib/foam/otel/isolated_exporters.rb,
lib/foam/otel/llm/anthropic_shim.rb,
lib/foam/otel/redacting_exporter.rb,
lib/foam/otel/ingest_metric_reader.rb,
lib/foam/otel/isolated_http_client.rb,
lib/foam/otel/llm/ruby_openai_shim.rb,
lib/foam/otel/llm/http_anthropic_shim.rb,
lib/foam/otel/prepend_collision_shield.rb

Defined Under Namespace

Modules: BeforeSend, Classifier, Diagnostics, Errors, ForkHooks, HeaderCapture, Ingest, IsolatedExporters, LLM, LoggerBridge, Metrics, PrependCollisionShield, Redaction, Resource, RuntimeMetrics, SessionStitching Classes: Config, GuardedLogRecordProcessor, GuardedSpanProcessor, Instance, IsolatedHttpClient, MaskedLogRecordView, MaskedSpanView, PayloadCapture, PayloadCaptureRailtie, RedactingLogRecordExporter, RedactingMetricsExporter, RedactingSpanExporter, Verdict

Constant Summary collapse

LEVELS =

---- shared ----------------------------------------------------------

{ trace: 1, debug: 5, info: 9, warn: 13, error: 17, fatal: 21 }.freeze
LEVEL_TEXT =
{ trace: "TRACE", debug: "DEBUG", info: "INFO", warn: "WARN",
error: "ERROR", fatal: "FATAL" }.freeze
REDACT_OPTION_FIELDS =

The exact field names the grouped redact: option accepts (fleet ruling 2026-07-28, docs/decisions/redact-object-detect-design.md §2): secrets unions with the legacy redact_keys, pii with redact_pii_keys, detect opts into the PII detection tier. Anything else raises at boot.

%w[secrets pii detect].freeze
CAPTURE_PAYLOAD_MODES =

The exact modes the capture_payloads: init option accepts (payload-capture mandate 2026-07-28; equivalent strings — trimmed, case-insensitive — are accepted and canonicalized to these symbols). :off is the default: zero body teeing, zero per-request allocation, no middleware shipped. The FOAM_CAPTURE_PAYLOADS operator env clamp (init.rb) accepts the same spellings and overrides the option.

%i[off errors always].freeze
VERSION =

The publish pipeline parses this literal (tools/publish-packages) — strict semver only; gem-style prereleases (0.1.0.alpha1) are unsupported. 1.0.0: first pass aligned to docs/BASE_PACKAGE_SPEC.md (fleet convention, matching the JS core's alignment bump). Breaking: the stamped/rid/ request-context architecture is gone; the init surface changed. 1.0.1 (#80): HTTP-client instrumentation class resolved for the loop guard. 1.0.2: FOAM_OTEL_TOKEN doc convention (token comment source change); drop-in. 1.2.1: review-fleet fixes. DELIBERATE WIRE CHANGE (GenAI-uniformity ruling): Gemini shim gen_ai.operation.name + span name "chat" → "generate_content", matching the js/python Gemini emissions. ruby_llm shim now captures agent-loop tool activity (tool_calls id/name/ arguments + tool_call_id) in gen_ai input/output messages. Contrib -logger pinned disabled in the sweep (foam's LoggerBridge supersedes it — rule 12 double-bridge guard). Sidekiq + Faraday floor entries wire-proven (rule 8b); Sidekiq :link posture documented (GOTCHAS G16). 1.3.0: ALWAYS-ON CREDENTIAL FLOOR (fleet ruling 2026-07-26; MINOR per the ruling — security-motivated wire change, no API/config change, docs/decisions/credential-denylist-design.md §9). Foam now masks, by default and in every signal, the VALUES of the fixed credential/secret header + field NAME list in contract/credential-denylist.json (seven headers + 52 keys) to the literal [REDACTED]. Exact name-equality, case-insensitive, dash/underscore-insensitive — never substring:

authorization_url is untouched. Everything else still exports RAW;
redact_keys/redact_pii_keys unchanged and additive; NO off switch.
Tenant additional_* processors now receive a MASKED read-only view
(closes the GOTCHAS F1 third-party-attribute divergence). If a
dashboard keyed off a raw credential value (it should not have), it
will now see [REDACTED].

1.4.0: VALUE-PATTERN SECRET LAYER + REDACTION-COVERAGE FIX + SECURITY FIXES (fleet ruling 2026-07-27, docs/decisions/security-fixes-design.md; MINOR per the ruling — security-motivated wire change, additive config). Foam now masks credential-SHAPED values (AWS/GCP/Azure keys, GitHub/GitLab tokens, Slack, Stripe, Anthropic/OpenAI keys, JWTs, private-key blocks, user:pass@ URI credentials, bearer/basic tokens) to [REDACTED] in every exported string value — regardless of field name — plus a generic keyword+entropy heuristic disableable with secret_heuristics: false (the named patterns and the credential floor cannot be disabled). Redaction now also reaches URL FRAGMENTS, bare k=v pair lists, nested containers (fail-closed), and structured LLM content BEFORE serialization (F-RB2). Metric datapoint attributes are not value-scanned. TLS peer verification is PINNED on every exporter — OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE is now INERT and loudly warned about (F-RB1, CWE-295). If a dashboard keyed off a raw token value (it should not have), it will now see [REDACTED]. 1.5.0: redact: OBJECT + OPT-IN PII DETECT TIER (fleet ruling 2026-07-28, docs/decisions/redact-object-detect-design.md; MINOR — additive, opt-in, zero default-path behavior change). init() and all three door-2 ingest entries gain ONE additive option, redact: — a Hash grouping the two key lists under intent-named fields (secrets: == redact_keys, pii: == redact_pii_keys; the legacy kwargs live on unchanged and UNION when both are set) plus a new opt-in detect: list of PII entity names (contract/pii-detect.json: email, phone, ssn, credit_card, ip) whose value shapes are masked in every exported string with typed placeholders ([EMAIL], [PHONE], [SSN], [CREDIT_CARD], [IP]) — span-only, Luhn-gated credit_card, idempotent over placeholders, same caps/fail-closed discipline and the same metric-label exemption as the secret layer. Unknown redact fields, non-Array values, and unknown detect entities raise at init (loud at boot). With redact: absent, behavior is byte-identical to 1.4.0. 1.6.0: DEFAULT-ON HTTP HEADER CAPTURE (2026-07-28; MINOR — additive wire change, no API/config change). New rule-32a module HeaderCapture. INBOUND: foam pre-installs the OFFICIAL rack instrumentation with the documented default header lists (DEFAULT_REQUEST_HEADERS / DEFAULT_RESPONSE_HEADERS) via its own allowed_request_headers / allowed_response_headers options (user ruling 2026-07-28 — the official gem's options are the seam, not a custom Rack middleware), so the rack SERVER span carries the LISTED headers as http.request,response.header. (underscored suffix — the gem's form). HONEST LIMIT: a named enumeration, NOT capture-all like js/python (the official option cannot express capture-all); extend per service via OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS — operator header config there (or an explicit instance via additional_instrumentations) is always respected, never overridden. OUTBOUND: a Faraday middleware (f.use :foam_otel_headers) enriching the official faraday client span with every outbound header (no upstream header option exists — accepted asymmetry). Credential floor masks the seven auth headers, zero config, no off switch. Net::HTTP/Excon/httprb/HTTPX outbound headers deliberately out of scope (their official instrumentations expose no hook — OTel-fundamentals ruling). Bodies are NOT captured (that is the separate capture_payloads pass). 1.7.0: OPT-IN HTTP BODY CAPTURE — capture_payloads: (2026-07-28 payload-capture mandate; MINOR — one additive init option, default OFF, zero default-path behavior change). New rule-32a module PayloadCapture: a Rack middleware teeing BODIES ONLY (headers stayed the 1.6.0 official-rack-options seam) onto the official rack SERVER span. Modes: :off (DEFAULT — zero teeing, zero per-request allocation, NO middleware shipped) | :errors (tee per-request, attach ONLY on exception-recorded-on-span or status >= 500; clean 2xx/4xx attach nothing) | :always; equivalent strings accepted; invalid init value raises at boot. FOAM_CAPTURE_PAYLOADS=off|errors|always is the operator env clamp overriding the option in both directions (invalid env warns + falls back, never crashes). Wire: http.request.body / http.response.body capped at 8192 chars + "…[truncated]", textual content-types only, identity encoding only, plus true http.request,response.body.size (declared Content-Length, else observed bytes; non-textual payloads sizes-only; HEAD/204/304 and other bodiless responses attach NO response-body attributes). Stream-safe: delegating tees only (never read ahead of the app on the rack input stream, re-reads after rewind/seek/pos= deduped, streaming #each bodies teed unbuffered with close preserved, Rack 3 #call-only bodies never wrapped). Redaction stays central at the exporter boundary — JSON-shaped body strings under the body names are now DEEP-masked by field name (floor + customer redact lists reach inside the body JSON). Rails auto-wiring (Railtie + init fallback) inserts the middleware ONLY when the mode is not :off; plain Rack adds one use line. 1.8.0: before_send: EXPORT HOOK (MINOR — one additive init option, default absent, zero default-path behavior change). One callable or an Array pipeline, run per record at the EXPORT BOUNDARY — after batching, BEFORE foam's redaction pass and the OTLP serialization — so a dropped record never leaves the process, and whatever a hook returns still rides the full credential floor / value layer / customer-key pass (before_send can never widen what ships). Spans and logs only (metrics are aggregated state, not records). Return the record (in-place mutation supported — attributes arrive unfrozen) to keep it, nil to drop it; a hook that raises or returns a foreign object drops the record LOUDLY and the batch reports FAILURE (fail-closed, Sentry beforeSend precedent, spec D6). A non-callable raises at boot; process-global like enabled/endpoint (a second init never changes it). Door-1 only — the door-2 ingest taps are unchanged. 1.8.1: before_send REVIEW HARDENING (adversarial review, 21 confirmed findings; PATCH — bugfix-only, no API change). CRITICAL: the OTLP encoders derive dropped_attributes_count as total_recorded - size (protobuf uint32) — a hook-ADDED attribute made the count negative, the encoder raised, and the WHOLE batch (healthy siblings included) was silently dropped; foam now re-normalizes total_recorded_* on every surviving record (nil-safe for hand-built replacement records; hook DELETES no longer fake an SDK-limit drop) — GOTCHAS F16. thaw now copies attribute VALUES and deep-copies structured log bodies (in-place mutation never bleeds into the live record or the tenant masked view; frozen values never fault). Boot validation gains an arity check: a lambda/Method that cannot take one positional arg (the Sentry ->(event, hint) port) raises at init instead of faulting on every record. Hook faults warn ONCE per batch (error classes only — messages can carry values; detail under diagnostics). validate_before_send! copies the caller's array (never freezes customer-owned input). Same-type replacement records documented (always accepted). Champ scenario spec: CI service-container fallback, pg_isready gate, leak cleanup, numeric version pick. Hooks run on the export thread — non-blocking only (documented, GOTCHAS F16). 1.9.0: EXPORT ISOLATION (export-isolation design 2026-07-29, docs/decisions/export-isolation-ruby.md, GOTCHAS F17; MINOR — reliability-motivated transport change, zero API/config/wire-shape change). Foam's OTLP delivery no longer travels Net::HTTP — the class every co-resident APM prepends onto (the wrong-arity private-helper collision class: every export crashes, zero rows land, healthy app, WITH the untraced flag active). All six exporters (door 1 + door 2 × traces/logs/metrics) now ride Foam::Otel::IsolatedHttpClient, an owned hand-written HTTP/1.1 sender: TLS VERIFY_PEER + hostname check, CA/mTLS options, HTTP(S)PROXY/NO_PROXY with CONNECT tunneling, per-request deadlines, keep-alive with one stale-socket retransmit, chunked/Connection: close reading, pid-keyed fork-safe reconnect. Upstream retry semantics preserved verbatim (429/503 honor Retry-After, 408/502/504 + transient socket errors back off with jitter, other 4xx drop); spec OTEL_EXPORTER_OTLP* env handling and the endpoint policy unchanged; untraced suppression kept as layer two. Capture untouched — foam still instruments the app's Net::HTTP exactly as before. ALSO (coexistence mandate, same release, GOTCHAS F18): the prepend- collision shield. Cross-vendor private-helper collisions on Net::HTTP (the live annotate_span_with_response! 3-arg vs 2-arg arity crash between a proprietary agent and the upstream contrib net_http patch foam ships) crash the APP's own HTTP calls; init now scans the prepend chain after the sweep and installs one frontmost signature-dispatch method per colliding name (caller-source gem first, then arity fit, else status quo ante) so every vendor's wrapper gets its own helper back. Collision-free chains (the common case) install NOTHING. Proven against the real agents in test-apps/ruby-coexistence (six vendors, both patch mechanisms, patched-client probe REQUIRED green). 1.9.1: before_send IDENTITY RESTORE (retro-probe class 14; PATCH — bugfix-only). resource / instrumentation_scope are process-global identity objects, and InstrumentationScope is a MUTABLE Struct: a hook renaming it corrupted the identity of every subsequent span/log from that instrument (proven live), and a wholesale record.resource = replacement shipped hook-authored resource attributes RAW (redaction never masks resource — the one can-never-widen gap). Hooks now receive a COPIED scope, and the ORIGINAL resource/scope are force-restored onto every survivor after the pipeline (identity is not hook-writable — the Go port's contract; README updated). 2.0.0: RUNTIME FLOOR RAISE (label-follows-tested-matrix ruling 2026-07-29). required_ruby_version >= 3.1 -> >= 3.4 — the version the CI matrix actually runs. The 3.1 floor was an unproven published promise: no CI leg ran below 3.4, and on 3.1–3.2 (both EOL) Bundler resolves an OLDER upstream otel gem set than the proven lockfile — a combination never tested. MAJOR: narrowing the supported-runtime label is breaking for installs on the dropped versions. No wire/API change — every 1.x contract above still holds. 2.1.0: LLM GAP-FILLER SHIMS + HTTP::Client COEXISTENCE SHIELD (MINOR — additive instrumentations, zero default-path behavior change for apps without the gems). Two new presence-checked, version-windowed LLM adapters closing the SDK-less-call gap: RubyOpenAIShim for the ruby-openai community client (OpenAI::Client#chat(parameters:) / #embeddings — presence by the parameters:-keyword SHAPE, never the bare method name: the official openai gem shares the OpenAI namespace and defines Client#chat as a zero-arg resource accessor, so a name check misfires on official-gem apps; both cross-gem negatives are pinned. Embeddings emit tokens + models ONLY — input documents are never captured, matching the python core's contrib openai-v2 path), and HttpAnthropicShim for hand-rolled HTTP.rb Anthropic clients (POST api.anthropic.com/v1/messages — EXACT path match; near-miss endpoints such as /v1/messages/count_tokens and /v1/messages/batches are pinned negative, never fabricated — including the persistent-client idiom HTTP.persistent(origin).post("/v1/messages", ...)), shape-checked per call so every other HTTP.rb request keeps only its plain transport span. Both inherit the LLM contract: RAW content capture redacted before serialization, one gen_ai span per logical call (transport spans from untraced-blind gems nest beneath — GOTCHAS G13), fail-to-dark everywhere. The transport shim is never the FIRST reader of the app's response: bounded JSON bodies (Content-Length ≤ 32 KiB) are captured and handed back re-readable (to_s/each/readpartial all intact); SSE (text/event-stream), chunked, and oversized bodies are never materialized (rule 49 cap; content omitted, activity + observed status survive). LLM.observe gains an optional error_detector: hook — HTTP.rb never raises on 4xx/5xx, so the shim turns the OBSERVED status into an ERROR span (rule 25); the status code rides http.response.status_code on the ERROR path ONLY (success gen_ai spans stay http.*-free, the fleet vocabulary) and error.type is the bare status string ("400"). Degraded gen_ai span names now fall back to " " (contract SPEC §10 shape) instead of a bare operation, and record_failure reads Faraday-style #response_status so ruby-openai provider errors carry their observed status. ALSO (coexistence mandate, GOTCHAS F18 twin): the prepend-collision shield now also scans HTTP::Client — dd-trace-rb's httprb patch and the contrib http patch define annotate_span_with_response! at non-identical signatures (3-arg vs 2-arg), the exact F18 Net::HTTP collision on a second class, live in the wild under datadog/auto_instrument (both colliding definitions verified at source — GOTCHAS F18); the signature-dispatch shim lets both agents keep working WHEN both vendor patches precede foam's init-time scan (F18 residual:

initialize foam after the vendor agents; a later-arriving patch is
unshielded until a re-init/fork re-scan).

2.1.1: LLM CAPTURE HARDENING + RESPONSE-STYLE MATRICES (PATCH — bug fixes and tests only, no new surface). HttpAnthropicShim's body capture: content-encoded (gzip) responses are never captured (the declared Content-Length is the COMPRESSED size — the 32 KiB cap could be bypassed by inflation; a post-materialization bytesize guard also covers lying Content-Length); a transport failure DURING capture now re-raises the ORIGINAL error on the app's own read (was: a misleading HTTP::StateError); if the body hand-back cannot be rebuilt, capture is skipped entirely. Degraded ruby-openai embeddings spans keep gen_ai.operation.name="embeddings" (the fallback stamped "chat" on a span named "embeddings openai"). GOTCHAS F18 citation hygiene: the three upstream issue links cited for the HTTP::Client collision could not be verified and are withdrawn; the colliding source definitions are the evidence. Tests: full response-style matrices for both providers — every documented Anthropic /v1/messages shape (parallel tool_use, thinking/redacted_thinking, all stop_reasons incl. model_context_window_exceeded, citation splitting, server-tool round trips, error envelopes 400-529, non-object/invalid JSON) and every documented OpenAI chat/embeddings shape (all finish_reasons

  • unknown pass-through, parallel/custom/legacy tool calls, refusal, n>1 choices, usage-detail variants, Faraday error classes with observed status, base64 embeddings) — plus gzip skip-capture, mid-read re-arm, and restored-body #each pins.
"2.1.1"
FOAM_OTEL_ENDPOINT =

The pinned fleet endpoint (BASE_PACKAGE_SPEC rule 23). Never an init option; the one override is the OTEL_EXPORTER_OTLP_ENDPOINT env var (rule 19), which init() honors with a loud warning.

"https://otel.api.foam.ai"
DISTRO_NAME =

Resource self-identification (BASE_PACKAGE_SPEC section 0, resource identity table): every export carries telemetry.distro.name = "foam" and telemetry.distro.version = the package version.

"foam"
MAX_REDACT_DEPTH =

Deep-redaction depth guard (rule 14). Beyond this, a value redacts in full rather than recursing further. Pinned at 10 fleet-wide (contract SPEC §4; credential-denylist-design §4 cross-core table) — every core walks to the same depth so the floor's coverage is byte-identical.

10
KNOWN_ENVIRONMENTS =

deployment.environment.name values that do NOT warn (section 0: the value is exported verbatim regardless; anything outside this set warns but is never rewritten).

%w[production staging development test].freeze
CREDENTIAL_HEADER_DENYLIST =

(a) The seven credential HEADERS, stored in wire-canonical dash form — matched as header names wherever headers are captured, including the semconv http.request,response.header. span-attribute forms.

%w[
  authorization
  proxy-authorization
  cookie
  set-cookie
  x-api-key
  x-auth-token
  www-authenticate
].freeze
CREDENTIAL_KEY_DENYLIST =

(b) The 52-entry KEY denylist, stored in canonical (lowercase, underscore) form, ASCII byte order — the deduplicated union of the seven headers, sentry-python's DEFAULT_DENYLIST + DEFAULT_PII_DENYLIST, and BASE_PACKAGE_SPEC rule 14's reference roots (design doc §1.2). Matched as attribute/field names at every depth the central engine walks. Every header above is also an entry here (subset invariant, design §1.1).

%w[
  _csrf
  _csrf_token
  _session
  _xsrf
  aiohttp_session
  api_key
  apikey
  auth
  authorization
  bearer
  card
  connect.sid
  cookie
  credential
  credentials
  csrf
  csrf_token
  csrftoken
  cvc
  cvv
  ip_address
  jwt
  mfa
  mysql_pwd
  otp
  passphrase
  passwd
  password
  phpsessid
  private_key
  privatekey
  proxy_authorization
  pwd
  remote_addr
  secret
  session
  sessionid
  set_cookie
  sid
  signature
  ssn
  symfony
  token
  user_session
  www_authenticate
  x_api_key
  x_auth_token
  x_csrftoken
  x_forwarded_for
  x_real_ip
  xsrf
  xsrf_token
].freeze
REDACTED =

The two mask shapes. A redact_keys match on a scalar keeps a debug tail; a redact_pii_keys match (and any non-scalar value under a redact_keys match) erases in full. MASK_BODY matches raven-ruby's STRING_MASK precedent.

"[REDACTED]"
MASK_BODY =

always exactly eight chars — never length-revealing

"********"
TAIL_MIN_LENGTH =

below this, no tail is safe

12
SECRET_SCAN_CAPS =

Execution bounds (design §V.5 / C-caps). All overflow behaviors are FAIL-CLOSED: mask, never raw-pass, never crash.

{
  value_scan_cap: 262_144,             # 256 KiB (design §V.5.4) — oversize ⇒ anchor-only decision
  pattern_max_matches_per_value: 1000, # match flood ⇒ whole value masked
  pem_block_cap: 16_384,               # BEGIN…END search window; truncated ⇒ mask to end of value
  h2_token_min: 24,
  h2_token_max: 512,
  query_pair_cap: 256,                 # C1.3 — overflow remainder masked; never an unbounded split (F-JS2 class)
  key_len_cap: 256,                    # C-caps — skip super-linear key work past this (F-PY2 class)
  key_token_cap: 32,                   # C-caps — matched keys are short
}.freeze
SECRET_VALUE_RULES =

Tier-1 named-provider patterns (design §V.2), byte-consistent with the python core's SECRET_VALUE_RULES. Each rule: lowercase literal :anchors (the regex NEVER runs unless an anchor occurs case-insensitively in the value — the gitleaks keywords pre-filter and the engine's linearity guarantee), a :regex whose group 1 is the secret span to mask, an optional :entropy floor over the candidate, and an optional :filter post-check (:digit / :classmix). No lookbehind, no possessive groups, bounded quantifiers (design §V.5.2 authoring rules — linted by spec/value_secret_layer_spec.rb). private-key-block and putty-private-key are LINEAR SCANS in the engine (design §V.2 normative algorithm), not table regexes.

[
  {
    id: "aws-access-key-id", ci: false, group: 1,
    regex: '\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[A-Z2-7]{16})\b',
    anchors: %w[akia asia abia acca agpa aida aroa aipa anpa anva a3t].freeze,
  }.freeze,
  {
    id: "aws-secret-key", ci: true, group: 1, entropy: 3.5,
    regex: '\baws[a-z0-9_ .\-]{0,25}?(?:key|secret|token)[a-z0-9_ .\-]{0,15}?[\'"]?\s{0,3}' \
           '(?:=|:|=>|:=|,)\s{0,3}[\'"]?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])',
    anchors: %w[aws].freeze,
  }.freeze,
  {
    id: "gcp-api-key", ci: false, group: 1,
    regex: '\b(AIza[0-9A-Za-z_\-]{35})(?![0-9A-Za-z_\-])',
    anchors: %w[aiza].freeze,
  }.freeze,
  {
    id: "azure-client-secret", ci: false, group: 1, entropy: 3.0,
    regex: '(?:^|[\s\'"`>=:(,])([A-Za-z0-9_~.]{3}[0-9]Q~[A-Za-z0-9_~.\-]{31,34})(?![A-Za-z0-9_~.\-])',
    anchors: %w[q~].freeze,
  }.freeze,
  {
    id: "azure-storage-key", ci: true, group: 1,
    regex: '\baccount[_\-]?key[\'"]?\s{0,3}[:=]\s{0,3}[\'"]?([A-Za-z0-9+/]{86}==)',
    anchors: %w[accountkey account_key account-key].freeze,
  }.freeze,
  {
    id: "github-token", ci: false, group: 1,
    regex: '\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36})(?![A-Za-z0-9_])',
    anchors: %w[ghp_ gho_ ghu_ ghs_ ghr_].freeze,
  }.freeze,
  {
    id: "github-fine-grained-pat", ci: false, group: 1,
    regex: '\b(github_pat_[A-Za-z0-9_]{82})(?![A-Za-z0-9_])',
    anchors: %w[github_pat_].freeze,
  }.freeze,
  {
    id: "gitlab-token", ci: false, group: 1,
    regex: '\b(glpat-[0-9A-Za-z_\-]{20,300}(?:\.[0-9a-z]{2,9})?|gl(?:ptt|rt|cbt)-[0-9A-Za-z_\-]{20,300}|' \
           'GR1348941[0-9A-Za-z_\-]{20})(?![0-9A-Za-z_\-.])',
    anchors: %w[glpat- glptt- glrt- glcbt- gr1348941].freeze,
  }.freeze,
  {
    id: "slack-token", ci: false, group: 1,
    regex: '\b(xox[abopsr]-(?:[0-9]{4,20}-){1,4}[0-9A-Za-z]{8,64}|xapp-[0-9]-[0-9A-Za-z_]{8,32}-[0-9]{8,16}-' \
           '[0-9a-f]{32,96}|xoxe(?:\.xox[bp])?-[0-9]-[0-9A-Za-z\-]{80,250})(?![0-9A-Za-z\-])',
    anchors: %w[xox xapp-].freeze,
  }.freeze,
  {
    id: "slack-webhook", ci: true, group: 1,
    regex: '(https://hooks\.slack\.com/(?:services|workflows|triggers)/[A-Za-z0-9+/]{20,120})(?![A-Za-z0-9+/])',
    anchors: %w[hooks.slack.com].freeze,
  }.freeze,
  {
    id: "stripe-secret-key", ci: false, group: 1,
    regex: '\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?![a-zA-Z0-9])',
    anchors: %w[sk_test sk_live sk_prod rk_test rk_live rk_prod].freeze,
  }.freeze,
  {
    id: "jwt", ci: false, group: 1,
    regex: '\b(ey[A-Za-z0-9]{17,512}\.ey[A-Za-z0-9/_\-]{17,8192}\.(?:[A-Za-z0-9/_\-]{10,1024}={0,2})?)' \
           '(?![A-Za-z0-9/_\-.=])',
    anchors: %w[eyj].freeze,
  }.freeze,
  {
    id: "uri-userinfo-credentials", ci: true, group: 1,
    regex: '\b[a-z][a-z0-9+.\-]{0,30}://([^/\s:@?#]{1,128}:[^/\s@]{1,256})@',
    anchors: %w[://].freeze,
  }.freeze,
  {
    id: "bearer-token", ci: true, group: 1, filter: :digit,
    regex: '\bbearer\s{1,4}([A-Za-z0-9._~+/=\-]{16,512})(?![A-Za-z0-9._~+/=\-])',
    anchors: %w[bearer].freeze,
  }.freeze,
  {
    id: "basic-auth-value", ci: true, group: 1, filter: :classmix,
    regex: '\bbasic\s{1,4}([A-Za-z0-9+/]{12,512}={0,2})(?![A-Za-z0-9+/=])',
    anchors: %w[basic].freeze,
  }.freeze,
  {
    id: "anthropic-api-key", ci: false, group: 1,
    regex: '\b(sk-ant-(?:admin|api)0[0-9]-[A-Za-z0-9_\-]{80,120}AA)(?![A-Za-z0-9_\-])',
    anchors: %w[sk-ant-].freeze,
  }.freeze,
  {
    id: "openai-api-key", ci: false, group: 1,
    regex: '\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_\-]{40,120}T3BlbkFJ[A-Za-z0-9_\-]{40,120}|' \
           'sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})(?![A-Za-z0-9_\-])',
    anchors: %w[t3blbkfj].freeze,
  }.freeze,
].freeze
SECRET_LINEAR_SCAN_ANCHORS =

Anchors for the two linear-scan rules (design §V.2: private-key-block — BEGIN…PRIVATE KEY blocks incl. RSA/EC/DSA/OPENSSH/ENCRYPTED/PGP, truncated block ⇒ mask to end of value, PUBLIC KEY/CERTIFICATE never match; and putty-private-key — the PPK header carries Private-Lines inline ⇒ whole value masked). Folded into the single anchor pre-filter so an OVERSIZED value carrying only a PEM block still fails closed.

["-----begin", "putty-user-key-file"].freeze
SECRET_HEURISTIC_H1 =

Tier-2 heuristics (design §V.3) — the ONLY value-layer piece with the documented opt-out (secret_heuristics: false). H1 = gitleaks generic-api-key (MIT): keyword-anchored assignment, entropy-gated, four-layer FP suppression. H2 = detect-secrets Base64HighEntropyString (Apache-2.0): whole-value single token only.

{
  id: "generic-api-key", ci: true, group: 1, entropy: 3.5, entropy_hex: 3.0,
  regex: '[\w.\-]{0,50}?(?:access|auth|api|credential|creds|key|passw(?:or)?d|secret|token)' \
         '(?:[ \t\w.\-]{0,20})[\s\'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[`\'"\s=]{0,5}' \
         '([\w.=\-]{10,150}|[a-z0-9][a-z0-9+/]{11,149}={0,3})(?:[`\'"\s;]|\\\\[nr]|$)',
  anchors: %w[access auth api credential creds key passw secret token].freeze,
}.freeze
SECRET_HEURISTIC_H2 =

H2 anchors are \A/\z (whole string) — Ruby's ^/$ are LINE anchors, so the fixture's ^…$ whole-value semantics translate to \A…\z here (recorded portability translation, design §V.2 conventions).

{
  id: "high-entropy-token", entropy: 4.5,
  token_regex: '\A[A-Za-z0-9+/_\-]{24,512}={0,2}\z',
  uuid_regex: '\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z',
  hex_regex: '\A[0-9a-fA-F]+\z',
}.freeze
SECRET_VALUE_PLACEHOLDERS =

Placeholder shapes (design §V.4, gitleaks global allowlist): a HEURISTIC candidate matching any of these whole is never masked (env refs, template vars, printf specs, repeated fillers). Applied to H1/H2 candidates only — NEVER to Tier-1 matches (no Tier-1 value allowlist: a value allowlist is a bypass shape; AKIAIOSFODNN7EXAMPLE is deliberately a POSITIVE).

[
  '\A(?:true|false|null|nil|none|undefined)\z',
  '\A(.)\1{3,}\z',
  '\A\$[A-Za-z_][A-Za-z0-9_]*\z',
  '\A\$\{[^}]{0,64}\}\z',
  '\A\{\{[^}]{0,64}\}\}\z',
  '\A%[A-Za-z_]{1,64}%\z',
  '\A@[A-Za-z_]{1,64}@\z',
  '\A%[sdifv]\z',
  '\A<[^>]{0,64}>\z',
].freeze
SECRET_KEY_CONTEXT_ALLOWLIST =

Key-context allowlist (design §V.3 / H1): when the matched assignment's KEY text is one of these non-secret names, the H1 candidate is NOT masked. Vendored subset of the gitleaks generic-rule allowlist, byte-consistent with the python core.

%w[
  api_version apiversion key_id keyid public_key publickey
  csrf_token primary_key partition_key sort_key idempotency_key
  cache_key routing_key aggregate_key
].freeze
SECRET_VALUE_STOPWORDS =

Stopwords (design §V.4, from the gitleaks curated list): a heuristic candidate CONTAINING any of these is rejected as a placeholder false positive. Applied to H1/H2 candidates only — never to Tier-1 matches. Byte-consistent with the python core's vendored subset; the full 1,436-entry list ships fleet-side in contract/secret-patterns.json.

%w[
  example placeholder redacted changeme changeit
  yourkey your_api_key your-api-key xxxxxxxx todo
  notreal loremipsum deadbeef 0000000000 1111111111
].freeze
PII_DETECT_ENTITIES =

---- THE PII DETECT TIER (fleet ruling 2026-07-28, FULLY OPT-IN) -------- docs/decisions/redact-object-detect-design.md §3; frozen fixture: contract/pii-detect.json (entity names + placeholders byte-identical, gate-checked by spec/pii_detect_spec.rb; behavior proven against the fixture's positive/negative vectors). Nothing here runs unless the customer lists the entity in init's redact.detect (or a door-2 tap's redact:) — the empty/absent list is today's raw-by-default behavior, byte-identical. The engine (redaction.rb detect_pii) runs this tier immediately AFTER the value-pattern secret layer and BEFORE customer key masking, masking ONLY the matched character span with the entity's typed :placeholder (surrounding text preserved verbatim; emitted placeholders never re-match — fixture "placeholder_idempotence").

Authoring rules mirror the secret layer's (design §V.5.2 / rule 49): compile-once at require (a bad pattern fails the require, loudly), bounded quantifiers only, no lookbehind, no atomic/possessive groups; :group names the span to mask (0 = the whole match; phone masks group 1 behind a consumed non-digit boundary — never a lookbehind). Patterns are byte-consistent with the python core's PII_DETECT_ENTITIES; the one recorded portability translation is phone's ^\A (Ruby's ^ is a LINE anchor — same translation note as SECRET_HEURISTIC_H2). :filter (:luhn) names a mandatory post-match validator — a card-shaped digit string failing Luhn is NEVER masked (fixture rule "checksum"). NOT detectable by design: person names and free-text prose (server-side NER scope — the README states this plainly).

{
  "email" => {
    placeholder: "[EMAIL]", group: 0,
    regex: '\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9\-]{1,63}' \
           '(?:\.[A-Za-z0-9\-]{1,63}){0,10}\.[A-Za-z]{2,24}\b',
  }.freeze,
  # Group 1 is the number; the leading alternation is a consumed boundary
  # guard (no lookbehind — rule-49 discipline). Separators are space/dash
  # only, so dotted versions (2024.10.05) and clock times (12:30:45)
  # never trip.
  "phone" => {
    placeholder: "[PHONE]", group: 1,
    regex: '(?:\A|[^0-9A-Za-z])((?:\+[0-9]{1,3}[ \-]?)?' \
           '(?:\([0-9]{1,4}\)[ \-]?|[0-9]{1,4}[ \-])' \
           '[0-9]{3,4}[ \-][0-9]{3,4})(?![0-9])',
  }.freeze,
  # Delimited 3-2-4 with a CONSISTENT delimiter (the \1 backreference —
  # a one-char group, linear by construction) — an undelimited 9-digit
  # run never matches. Area 000/666/9xx, group 00, and serial 0000 are
  # structurally invalid SSNs and ride raw.
  "ssn" => {
    placeholder: "[SSN]", group: 0,
    regex: '\b(?!000|666|9[0-9]{2})[0-9]{3}([ \-])' \
           '(?!00)[0-9]{2}\1(?!0000)[0-9]{4}\b',
  }.freeze,
  # 13-19 digits in 4-4-4-rest grouping (space/dash/contiguous). The
  # regex is deliberately loose on grouping; the LUHN filter is the
  # normative gate (fixture "checksum" rule).
  "credit_card" => {
    placeholder: "[CREDIT_CARD]", group: 0, filter: :luhn,
    regex: '\b[0-9]{4}(?:[ \-]?[0-9]{4}){2}[ \-]?[0-9]{1,7}\b',
  }.freeze,
  # IPv4 with per-octet range validation (999.1.1.1 and 4-digit octets
  # ride raw), full 8-group IPv6, and ::-compressed IPv6.
  "ip" => {
    placeholder: "[IP]", group: 0,
    regex: '\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}' \
           '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b' \
           '|\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b' \
           '|\b(?:[0-9A-Fa-f]{1,4}:){1,6}:' \
           '(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?(?![0-9A-Fa-f:])',
  }.freeze,
}.freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.active_configObject



49
50
51
# File 'lib/foam/otel/config.rb', line 49

def active_config
  @active_config ||= blank_config
end

Class Method Details

.add_event(name, attributes: nil) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
# File 'lib/foam/otel/api.rb', line 136

def add_event(name, attributes: nil)
  return false if helpers_disabled?

  span = OpenTelemetry::Trace.current_span
  return false unless span.respond_to?(:recording?) && span.recording?

  span.add_event(name.to_s, attributes: stringify(attributes))
  true
rescue StandardError, SystemStackError
  false
end

.add_up_down_counter(name, by:, attributes: nil) ⇒ Object



169
170
171
# File 'lib/foam/otel/api.rb', line 169

def add_up_down_counter(name, by:, attributes: nil)
  Metrics.add_up_down_counter(name, by: by, attributes: attributes)
end

.after_fork!Object

Restart foam's PeriodicMetricReader export threads after a fork (the trace/log batch processors self-heal on their own — GOTCHAS R1). Walks BOTH registries: foam's own door-1 readers AND the door-2 retires foam's pipelines never orphans a tap riding the CUSTOMER'S pipeline — GOTCHAS R7). Called automatically by foam's Process._fork hook; also safe to call from Puma on_worker_boot / Unicorn after_fork.



236
237
238
239
240
241
242
243
# File 'lib/foam/otel/api.rb', line 236

def after_fork!
  ((@metric_readers || []) + (@ingest_taps || [])).each do |reader|
    reader.after_fork if reader.respond_to?(:after_fork)
  rescue StandardError
    nil
  end
  nil
end

.blank_configObject

The inert config the helpers read before init() runs (everything a no-op needs: empty redaction lists, export disabled).



37
38
39
40
41
42
43
44
45
46
47
# File 'lib/foam/otel/config.rb', line 37

def blank_config
  Config.new(
    name: "", environment: "", version: nil, enabled: false,
    redact_keys: [].freeze, redact_pii_keys: [].freeze,
    redact_detect: [].freeze,
    ignored_outbound_hosts: [].freeze,
    diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
    secret_heuristics: true, capture_payloads: :off,
    before_send: [].freeze
  ).freeze
end

.create_ingest_log_record_processor(token:, environment:, redact_keys: nil, redact_pii_keys: nil, redact: nil, diagnostics: false, secret_heuristics: true) ⇒ Object

Door-2 logs entry: a LogRecordProcessor duck for THEIR LoggerProvider.



55
56
57
58
59
60
61
62
# File 'lib/foam/otel/ingest.rb', line 55

def create_ingest_log_record_processor(token:, environment:, redact_keys: nil,
                                       redact_pii_keys: nil, redact: nil, diagnostics: false,
                                       secret_heuristics: true)
  Ingest.build(:logs, token: token, environment: environment,
                      redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
                      redact: redact, diagnostics: diagnostics,
                      secret_heuristics: secret_heuristics)
end

.create_ingest_metric_reader(token:, environment:, redact_keys: nil, redact_pii_keys: nil, redact: nil, diagnostics: false, secret_heuristics: true) ⇒ Object

Door-2 metrics entry: a PeriodicMetricReader subclass for THEIR MeterProvider#add_metric_reader (the back-filling attach point).



66
67
68
69
70
71
72
73
# File 'lib/foam/otel/ingest.rb', line 66

def create_ingest_metric_reader(token:, environment:, redact_keys: nil,
                                redact_pii_keys: nil, redact: nil, diagnostics: false,
                                secret_heuristics: true)
  Ingest.build(:metrics, token: token, environment: environment,
                         redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
                         redact: redact, diagnostics: diagnostics,
                         secret_heuristics: secret_heuristics)
end

.create_ingest_span_processor(token:, environment:, redact_keys: nil, redact_pii_keys: nil, redact: nil, diagnostics: false, secret_heuristics: true) ⇒ Object

Door-2 traces entry: a SpanProcessor-duck instance for THEIR TracerProvider (their setup block / add_span_processor). Foam's copy is stamped + masked at export; their spans are never touched. redact: is the grouped redaction object (fleet ruling 2026-07-28, same shape and union semantics as init's — secrets ∪ redact_keys, pii ∪ redact_pii_keys, detect = the opt-in PII detection tier), scoped to this tap like the legacy kwargs.



45
46
47
48
49
50
51
52
# File 'lib/foam/otel/ingest.rb', line 45

def create_ingest_span_processor(token:, environment:, redact_keys: nil,
                                 redact_pii_keys: nil, redact: nil, diagnostics: false,
                                 secret_heuristics: true)
  Ingest.build(:traces, token: token, environment: environment,
                       redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
                       redact: redact, diagnostics: diagnostics,
                       secret_heuristics: secret_heuristics)
end

.flushObject

Force-flush foam's buffered pipelines. Call before a short-lived process exits (rake, cron, serverless handler end). Never raises.



207
208
209
210
211
212
213
214
215
216
217
# File 'lib/foam/otel/api.rb', line 207

def flush
  (@flushables || []).dup.each do |flushable|
    flushable.force_flush
  rescue StandardError, SystemStackError
    # SystemStackError is not a StandardError: a poisoned (e.g. cyclic)
    # payload deep in a pipeline must still never escape into the
    # customer's thread (rule 9).
    nil
  end
  nil
end

.get_logger(name = "foam") ⇒ Object



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/foam/otel/api.rb', line 43

def get_logger(name = "foam")
  provider = OpenTelemetry.logger_provider
  warn_if_displaced(:logs, provider)
  # Never register into the PRE-INIT proxy registry (GOTCHAS L1): the
  # logs-api 0.4.1 ProxyLoggerProvider#delegate= upgrades cached proxy
  # loggers with a POSITIONAL provider.logger(name, version) call, but
  # the logs SDK's #logger is keyword-only — one cached proxy logger
  # makes init's logger_provider assignment raise and would leave the
  # logs pipeline dark for the process. Pre-init logs are a silent
  # no-op by contract, so hand back the API's no-op Logger directly.
  # (Same feature-detected Internal read as the classifier — rule 42,
  # GOTCHAS G14; when the constant is missing this check degrades to
  # the plain provider call below.)
  if defined?(OpenTelemetry::Internal::ProxyLoggerProvider) &&
     provider.instance_of?(OpenTelemetry::Internal::ProxyLoggerProvider)
    return noop_logger
  end

  begin
    provider.logger(name: name) # the logs SDK's keyword-only signature
  rescue ArgumentError
    provider.logger(name) # a positional-signature (older/foreign) provider
  end
end

.get_meter(name = "foam") ⇒ Object



37
38
39
40
41
# File 'lib/foam/otel/api.rb', line 37

def get_meter(name = "foam")
  provider = OpenTelemetry.meter_provider
  warn_if_displaced(:metrics, provider)
  provider.meter(name)
end

.get_trace_contextObject

The active trace/span ids as lowercase hex, or nil when no span is recording — never fabricates ids.



150
151
152
153
154
155
156
157
158
# File 'lib/foam/otel/api.rb', line 150

def get_trace_context
  span = OpenTelemetry::Trace.current_span
  ctx = span.respond_to?(:context) ? span.context : nil
  return nil if ctx.nil? || !ctx.valid?

  { trace_id: ctx.hex_trace_id, span_id: ctx.hex_span_id }
rescue StandardError, SystemStackError
  nil
end

.get_tracer(name = "foam") ⇒ Object

---- passthroughs ---------------------------------------------------- The real OTel objects on foam's pipeline. Everything unwrapped (links, span kinds, observable instruments, baggage) is reachable here — this keeps the helper set CLOSED. After init they export to foam; when a foreign SDK owns a signal, they emit through that provider (rule 18 B).



31
32
33
34
35
# File 'lib/foam/otel/api.rb', line 31

def get_tracer(name = "foam")
  provider = OpenTelemetry.tracer_provider
  warn_if_displaced(:traces, provider)
  provider.tracer(name)
end

.increment_counter(name, by: 1, attributes: nil) ⇒ Object

---- metrics (one helper per synchronous instrument) -----------------



161
162
163
# File 'lib/foam/otel/api.rb', line 161

def increment_counter(name, by: 1, attributes: nil)
  Metrics.increment_counter(name, by: by, attributes: attributes)
end

.init(name:, environment:, enabled:, token: nil, version: nil, redact_keys: nil, redact_pii_keys: nil, redact: nil, additional_instrumentations: nil, additional_span_processors: nil, additional_log_record_processors: nil, additional_metric_readers: nil, ignored_outbound_hosts: nil, diagnostics: false, secret_heuristics: true, capture_payloads: :off, before_send: nil) ⇒ Object

rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/foam/otel/init.rb', line 53

def init(name:, environment:, enabled:, token: nil, version: nil,
         redact_keys: nil, redact_pii_keys: nil, redact: nil,
         additional_instrumentations: nil,
         additional_span_processors: nil,
         additional_log_record_processors: nil,
         additional_metric_readers: nil,
         ignored_outbound_hosts: nil,
         diagnostics: false,
         secret_heuristics: true,
         capture_payloads: :off,
         before_send: nil)
  # Identity is validated at boot (rule 10): a blank required value is a
  # programming error the engineer must catch on their machine.
  validate_present!(:name, name)
  validate_present!(:environment, environment)
  # enabled must be a real boolean — a truthy non-true value (the string
  # "true", 1, "yes") would otherwise SILENTLY go inert. Complain at boot
  # (rule 10) rather than disable a service that looks configured.
  unless [true, false].include?(enabled)
    raise ArgumentError, "Foam::Otel.init requires enabled: true or false (got #{enabled.inspect})"
  end

  # OTEL_SDK_DISABLED=true is the operator kill switch and SUPERSEDES
  # enabled: true (rule 19). Foam bypasses SDK.configure, so it must honor
  # the var itself. Matched trimmed + case-insensitive so the fleet's one
  # emergency switch behaves identically across languages (JS/Python
  # trim + downcase; the OTel boolean-env convention is case-insensitive).
  killed = kill_switch_active?
  effective_enabled = enabled && !killed

  endpoint = resolve_endpoint
  warn_inert_per_signal_endpoints(endpoint)
  # The grouped `redact:` object (fleet ruling 2026-07-28) resolves here
  # too — an unknown field, a non-Array value, or an unknown detect
  # entity raises INSIDE resolve_config, loudly, at boot (rule 10).
  config = resolve_config(
    name: name, environment: environment, version: version,
    enabled: effective_enabled,
    redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, redact: redact,
    ignored_outbound_hosts: ignored_outbound_hosts,
    diagnostics: diagnostics, endpoint: endpoint,
    secret_heuristics: secret_heuristics,
    # The payload-capture mode: the init option validated loudly (an
    # invalid option raises inside resolve_config), with the
    # FOAM_CAPTURE_PAYLOADS operator env clamp applied first (a valid
    # env value overrides the option in BOTH directions; an invalid
    # one warns + falls back — never a crashed boot).
    capture_payloads: resolve_capture_payloads(capture_payloads),
    # The customer's export hook(s): a non-callable raises INSIDE
    # resolve_config, loudly, at boot (rule 10).
    before_send: before_send
  )
  # The heuristic-tier opt-out is an explicit, audited customer decision
  # (security-fixes-design §V.8) — always one loud line, never silent.
  if config.secret_heuristics == false
    Diagnostics.warn("secret_heuristics: false — the generic keyword/entropy secret heuristics are " \
                     "DISABLED. The named value-pattern tier, the credential floor and the coverage " \
                     "contract remain fully active (they have no off switch).")
  end
  # Calling init twice warns and returns the first instance (rule:
  # lifecycle). The redaction lists (and diagnostics) refresh so the
  # helpers read the latest customer lists — but enabled/endpoint/identity
  # are process-global and are NEVER changed by a second init: overwriting
  # `enabled` here would leave foam HALF-dark (a second init(enabled:false)
  # would no-op the helpers while the already-registered providers keep
  # exporting Rack/Net::HTTP spans — the operator believes foam is off
  # while data still flows), and the reverse would "wake" a service booted
  # disabled without ever building a pipeline. Merge ONLY the redact lists.
  if @initialized
    self.active_config = refresh_post_init_config(
      active_config, redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
      redact: redact, diagnostics: diagnostics, secret_heuristics: secret_heuristics
    )
    Diagnostics.warn("init already called — returning the existing instance (providers are process-global; " \
                     "enabled and endpoint cannot change after the first init — only the redaction lists refresh)")
    return @instance
  end
  self.active_config = config

  warn_environment(environment)
  warn_missing_version(version)

  unless effective_enabled
    if killed
      # The kill switch OVERRIDES the code's enabled: true — an operator
      # intervention is worth one loud line (rule 19 env table).
      Diagnostics.warn("OTEL_SDK_DISABLED=true — foam is fully off (no SDK, no export)")
    else
      # enabled: false is the DOCUMENTED tests/CI posture (README
      # scenario 6): an EXPECTED state stays silent (rule 10). The
      # posture line is still visible under diagnostics: true.
      Diagnostics.info("enabled: false — foam is inert (no SDK loaded, no providers, no export)")
    end
    @initialized = true
    return @instance = Instance.new(config)
  end

  # token is required WHEN enabled, wired explicitly — never an env
  # fallback (section 0). Validated only on the enabled path.
  if token.nil? || token.to_s.strip.empty?
    raise ArgumentError, "Foam::Otel.init requires a non-empty token when enabled"
  end

  warn_inert_ssl_verify_none
  require_sdk!
  configure_propagation

  # Providers foam registered in the PARENT process — non-empty only on
  # a post-fork re-init (the fork hook cleared @initialized, GOTCHAS
  # R1). A slot still holding one of these is foam's OWN to re-own
  # (retiring the inherited pipelines and minting a fresh resource — a
  # NEW service.instance.id per worker, section 0); a genuinely foreign
  # provider is never touched.
  inherited = @foam_providers.dup
  @inherited_retired = false
  resource = Resource.build(config)
  headers = { "Authorization" => "Bearer #{token}" }
  instance = Instance.new(config)

  # Each slot is classified IMMEDIATELY before its own setter — never
  # as an up-front classify-all pass. Ruby has no atomic set-once
  # attempt (rule 18 step 4's Ruby row: every setter assigns
  # unconditionally, so attempt-then-read would DISPLACE an incumbent —
  # RESEARCH.md §2 / GOTCHAS F7), so a pre-read is forced; classifying
  # per signal at the last moment keeps the residual pre-read→set race
  # window as narrow as the platform allows (GOTCHAS F8).
  traces_verdict = register_free_signal(:traces, instance, inherited) do
    setup_traces(endpoint, headers, resource, config, additional_span_processors)
  end
  register_free_signal(:logs, instance, inherited) do
    setup_logs(endpoint, headers, resource, config, additional_log_record_processors)
  end
  register_free_signal(:metrics, instance, inherited) do
    setup_metrics(endpoint, headers, resource, config, additional_metric_readers)
  end

  activate_instrumentations(config, additional_instrumentations, traces_verdict)
  activate_floor_extensions
  ForkHooks.install!

  at_exit { instance.shutdown }
  Diagnostics.info("init ok for #{name} (#{environment}) -> #{endpoint}")
  @initialized = true
  @instance = instance
end

.log(severity, body, attributes: nil) ⇒ Object

---- logs ------------------------------------------------------------ Emit a trace-correlated log record on foam's log pipeline. severity is an OTel severity NUMBER (1..24) or a level symbol (:info, :warn, :error, :debug, :fatal, :trace). Safe pre-init (no-op). Never raises.



182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/foam/otel/api.rb', line 182

def log(severity, body, attributes: nil)
  return true if helpers_disabled? # disabled means fully dark — accepted, silently dropped

  get_logger.on_emit(
    severity_number: severity_number(severity),
    severity_text: severity_text(severity),
    body: mask_log_body(body),
    attributes: stringify(attributes),
    context: OpenTelemetry::Context.current
  )
  true
rescue StandardError, SystemStackError
  false
end

.mark_forked!Object

Called by the fork hook IN THE CHILD: clears the idempotency guard so the README's per-worker init is honored as a real re-init (the child then reclaims foam's inherited slots and mints a fresh service.instance.id — see classify_for_registration). Everything else is left running for workers that never re-init.



205
206
207
# File 'lib/foam/otel/init.rb', line 205

def mark_forked!
  @initialized = false
end

.record_exception(error) ⇒ Object

Records an exception on the active span (status ERROR) when one is recording. Accepts customer error classes; NEVER notifies a vendor tracker. Deduped per span. Never raises.



95
96
97
98
99
100
# File 'lib/foam/otel/api.rb', line 95

def record_exception(error)
  return nil if helpers_disabled?

  Errors.record_once(OpenTelemetry::Trace.current_span, error)
  nil
end

.record_histogram(name, value, attributes: nil) ⇒ Object



165
166
167
# File 'lib/foam/otel/api.rb', line 165

def record_histogram(name, value, attributes: nil)
  Metrics.record_histogram(name, value, attributes: attributes)
end

.redact(value) ⇒ Object

---- redaction ------------------------------------------------------- The same fail-closed engine foam uses internally: mask a value the caller knows is sensitive (scalar → tail mask, everything else → [REDACTED]); empty on hard failure, never the unredacted payload.



201
# File 'lib/foam/otel/api.rb', line 201

def redact(value) = Redaction.redact(value)

.reset_for_tests!Object

Test hook: full reset of module state (NOT for production — OTel globals cannot be cleanly re-registered after a real shutdown).



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/foam/otel/init.rb', line 212

def reset_for_tests!
  @initialized = false
  @instance = nil
  @foam_providers = []
  @flushables = []
  @metric_readers = []
  @ingest_taps = []
  @foam_registered = {}
  @displacement_warned = {}
  @inherited_retired = false
  self.active_config = blank_config
  Metrics.reset_for_tests!
  RuntimeMetrics.reset_for_tests!
  Redaction.reset_secret_scan_warnings!
  HeaderCapture.reset_for_tests!
  PayloadCapture.reset_for_tests!
end

.resolve_config(name:, environment:, version:, enabled:, redact_keys:, redact_pii_keys:, ignored_outbound_hosts:, diagnostics:, endpoint:, secret_heuristics: true, redact: nil, capture_payloads: :off, before_send: nil) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/foam/otel/config.rb', line 55

def resolve_config(name:, environment:, version:, enabled:,
                   redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
                   diagnostics:, endpoint:, secret_heuristics: true, redact: nil,
                   capture_payloads: :off, before_send: nil)
  keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
  # capture_payloads is validated LOUDLY at boot (rule 10, exactly like
  # the redact: object): a value outside CAPTURE_PAYLOAD_MODES (or its
  # string spellings) raises here — never a silent :off.
  capture_mode = validate_capture_payloads!(capture_payloads)
  Config.new(
    name: name,
    environment: environment,
    version: version,
    enabled: enabled,
    # Opt-in CUSTOMER redaction (holistic redesign), additive above the
    # always-on credential floor (which is a module constant, NOT config
    # state — no config shape can alter it). Empty (the default) → no
    # customer redaction, raw capture above the floor. Stored lowercased
    # for the engine's case-insensitive substring match. The grouped
    # `redact:` object (2026-07-28 ruling) UNIONS with the legacy flat
    # lists; redact.detect is the opt-in PII detection tier's entity
    # list — empty (the default) means zero PII detection.
    redact_keys: keys,
    redact_pii_keys: pii,
    redact_detect: detect,
    ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
    diagnostics: diagnostics ? true : false,
    endpoint: endpoint,
    # The value-pattern layer's HEURISTIC tier opt-out (security-fixes-
    # design §V.8): false disables H1/H2 ONLY — the named Tier-1
    # patterns, the credential floor and the coverage contract are
    # module-constant machinery no config shape can narrow. Anything
    # but literal false means ON (default-on preserves the fleet's
    # no-leakage bar; disabling is an explicit, audited decision).
    secret_heuristics: secret_heuristics == false ? false : true,
    # The payload-capture mode (2026-07-28 mandate): :off (the default
    # — zero teeing, zero middleware) | :errors | :always, canonical
    # symbol form. init.rb applies the FOAM_CAPTURE_PAYLOADS operator
    # env clamp BEFORE this resolves.
    capture_payloads: capture_mode,
    # The customer's export hook(s) (1.8.0): one callable or an Array
    # pipeline, validated LOUDLY at boot (rule 10 — a non-callable is a
    # programming error). Stored as a frozen Array; empty (the default)
    # means the exporter chain gains no before_send stage at all.
    before_send: validate_before_send!(before_send)
  ).freeze
end

.set_attribute(key, value) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/foam/otel/api.rb', line 102

def set_attribute(key, value)
  return false if helpers_disabled?

  span = OpenTelemetry::Trace.current_span
  return false unless span.respond_to?(:recording?) && span.recording?

  # Capture-time pass (rule 18 C.3): the always-on credential floor plus
  # any configured redact_keys/redact_pii_keys, so the value lands
  # ALREADY-MASKED — tenant processors and inert-mode providers only ever
  # see the redacted view of helper-set data. The exporter pass
  # re-applies (idempotent). Values under unmatched names are captured
  # RAW. scrub_utf8 is encoding hygiene, not masking: an invalid-UTF-8
  # value would make the upstream OTLP encoder drop the WHOLE batch
  # (rule 15).
  span.set_attribute(key.to_s, Redaction.mask_one(key.to_s, Redaction.scrub_utf8(value), active_config))
  true
rescue StandardError, SystemStackError
  # SystemStackError is not a StandardError: a poisoned key/value (e.g. a
  # recursive #to_s/#hash) must never crash the caller's thread (rule 9).
  false
end

.set_attributes(attributes) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
# File 'lib/foam/otel/api.rb', line 124

def set_attributes(attributes)
  return false if helpers_disabled?

  span = OpenTelemetry::Trace.current_span
  return false unless span.respond_to?(:recording?) && span.recording?

  span.add_attributes(stringify(attributes))
  true
rescue StandardError, SystemStackError
  false
end

.set_metric(name, value, attributes: nil) ⇒ Object

Gauge: the last-value instrument (set_metric in every core).



174
175
176
# File 'lib/foam/otel/api.rb', line 174

def set_metric(name, value, attributes: nil)
  Metrics.set_metric(name, value, attributes: attributes)
end

.shutdownObject

Shut down foam's pipelines (flush + provider shutdown). Idempotent; normal process exit is covered by the at_exit hook init() installs. Never raises.



222
223
224
225
226
227
# File 'lib/foam/otel/api.rb', line 222

def shutdown
  @instance&.shutdown
  nil
rescue StandardError, SystemStackError
  nil
end

.span(name, attributes: nil, &block) ⇒ Object

A custom span around a block. Re-raises the block's error identically, records the exception + ERROR status, and ALWAYS ends the span (rule 9 / never-ended-span leak). Safe pre-init (no-op span). Argument preparation NEVER throws (rule 9): garbage attributes degrade to nil, a poisoned name to "unnamed" — the block always runs and only ITS error re-raises. When foam is initialized disabled, the block runs on a non-recording no-op span: disabled beats everything, including inert-mode emission through a foreign provider (section 0).



78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/foam/otel/api.rb', line 78

def span(name, attributes: nil, &block)
  span_name = begin
    name.to_s
  rescue StandardError, SystemStackError
    # SystemStackError is not a StandardError: a name object whose #to_s
    # recurses unboundedly must degrade to "unnamed", never escape into
    # the customer's thread (rule 9).
    "unnamed"
  end
  return noop_tracer.in_span(span_name, &block) if helpers_disabled?

  get_tracer.in_span(span_name, attributes: stringify(attributes), &block)
end