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):secretsunions with the legacy redact_keys,piiwith redact_pii_keys,detectopts 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 oneuseline. 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 liveannotate_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 wholesalerecord.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
- .add_event(name, attributes: nil) ⇒ Object
- .add_up_down_counter(name, by:, attributes: nil) ⇒ Object
-
.after_fork! ⇒ Object
Restart foam's PeriodicMetricReader export threads after a fork (the trace/log batch processors self-heal on their own — GOTCHAS R1).
-
.blank_config ⇒ Object
The inert config the helpers read before init() runs (everything a no-op needs: empty redaction lists, export disabled).
-
.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.
-
.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).
-
.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).
-
.flush ⇒ Object
Force-flush foam's buffered pipelines.
- .get_logger(name = "foam") ⇒ Object
- .get_meter(name = "foam") ⇒ Object
-
.get_trace_context ⇒ Object
The active trace/span ids as lowercase hex, or nil when no span is recording — never fabricates ids.
-
.get_tracer(name = "foam") ⇒ Object
---- passthroughs ---------------------------------------------------- The real OTel objects on foam's pipeline.
-
.increment_counter(name, by: 1, attributes: nil) ⇒ Object
---- metrics (one helper per synchronous instrument) -----------------.
-
.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.
-
.log(severity, body, attributes: nil) ⇒ Object
---- logs ------------------------------------------------------------ Emit a trace-correlated log record on foam's log pipeline.
-
.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).
-
.record_exception(error) ⇒ Object
Records an exception on the active span (status ERROR) when one is recording.
- .record_histogram(name, value, attributes: nil) ⇒ Object
-
.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.
-
.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).
- .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
- .set_attribute(key, value) ⇒ Object
- .set_attributes(attributes) ⇒ Object
-
.set_metric(name, value, attributes: nil) ⇒ Object
Gauge: the last-value instrument (set_metric in every core).
-
.shutdown ⇒ Object
Shut down foam's pipelines (flush + provider shutdown).
-
.span(name, attributes: nil, &block) ⇒ Object
A custom span around a block.
Class Attribute Details
.active_config ⇒ Object
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_config ⇒ Object
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 |
.flush ⇒ Object
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_context ⇒ Object
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 |
.shutdown ⇒ Object
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 |