Module: Foam::Otel

Defined in:
lib/foam/otel/api.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/diagnostics.rb,
lib/foam/otel/redacting_exporter.rb,
lib/foam/otel/ingest_metric_reader.rb

Defined Under Namespace

Modules: Classifier, Diagnostics, Errors, ForkHooks, Ingest, Metrics, Redaction, Resource Classes: Config, GuardedLogRecordProcessor, GuardedSpanProcessor, Instance, 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
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.1.0"
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.

12
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
BARE_SECRET_ROOTS =

---- Secrets floor (BASE_PACKAGE_SPEC rule 14) -------------------------- RESEARCHED, not invented (provenance cited in RESEARCH.md): substring roots curated from the consensus of OSS scrubbers — raven-ruby, sentry-python/js, Elastic APM sanitize_field_names, is-secret, @zapier/secret-scrubber, HyperDX, Instana, Rails filter_parameters. Matching is SUBSTRING, case-insensitive, over a key NORMALIZED by lowercasing and folding - _ . and space (so X-Api-Keyapi_keyapiKey).

BARE_SECRET_ROOTS are separator-free and matched against the normalized key. They are distinctive enough to never over-redact.

%w[
  secret
  token
  password
  passwd
  pwd
  passphrase
  apikey
  secretkey
  accesskey
  privatekey
  credential
  credentials
  session
  phpsessid
  jsessionid
  connectsid
  aspnetsessionid
  cookie
  csrf
  xsrf
  jwt
  bearer
  signature
  mysqlpwd
  proxyauthorization
  crypt
  creditcard
  cardnumber
].freeze
GUARDED_SECRET_ROOTS =

GUARDED roots are short/ambiguous — matched with word boundaries on the lowercased key (separators kept) so they never OVER-redact (lost observability, not extra safety). key/sid/id/salt are REJECTED as roots entirely: bare key eats idempotency_key/cache_key/public_key; bare sid eats consider/reside/president. auth must miss author; card must miss dashboard/wildcard/postcard.

%w[ssn otp mfa 2fa sso saml cvv cvc dni].freeze
GUARDED_SECRET_RE =

Precompiled once at load: one token-boundary regex matching ANY guarded root, so the hot path never recompiles an interpolated regex per key.

/(?<![a-z0-9])(?:#{GUARDED_SECRET_ROOTS.map { |r| Regexp.escape(r) }.join('|')})(?![a-z0-9])/
SECRET_VALUE_PATTERNS =

---- Value patterns (rule 14 second pass) ------------------------------ A pass over string VALUES/bodies so a secret under an UNLISTED key still masks. \b-bounded (usable both whole-value and embedded), prefix- anchored, low false-positive vendor key shapes catalogued by OSS secret scanners (detect-secrets Apache-2.0, secretlint MIT, gitleaks MIT). Bare high-entropy / plain 32-hex / base64 / credit-card-Luhn are deliberately OMITTED (opt-in only — they destroy observability without a nearby keyword). Cited in RESEARCH.md.

[
  %r{\b(?:Bearer|Basic)\s+[A-Za-z0-9\-._~+/=]{8,}}i,                     # auth header values
  /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/,      # JWT
  /\b(?:sk|pk|rk)[-_](?:ant-)?[A-Za-z0-9_-]{16,}\b/,                     # OpenAI/Anthropic/generic
  /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/,                                 # Stripe live
  /\bgh[pousr]_[A-Za-z0-9]{16,}\b/,                                      # GitHub tokens
  /\bgithub_pat_[A-Za-z0-9_]{16,}\b/,                                    # GitHub fine-grained PAT
  /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,                                    # Slack
  /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/,                            # AWS access key id
  /\bAIza[0-9A-Za-z\-_]{20,}\b/,                                         # Google API key
  /\bglpat-[A-Za-z0-9\-_]{16,}\b/,                                       # GitLab PAT
  /\bnpm_[A-Za-z0-9]{16,}\b/,                                            # npm
  /\bpypi-[A-Za-z0-9\-_]{16,}\b/,                                        # PyPI
  /\bhf_[A-Za-z0-9]{16,}\b/,                                             # HuggingFace
  /\bshp(?:ss|at|ca|pa|a)_[A-Za-z0-9]{16,}\b/,                          # Shopify
  /\bdapi[a-f0-9]{32}\b/i,                                               # Databricks
  /-----BEGIN[A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END[A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----/, # PEM
].freeze
SECRET_VALUE_RE =

One unioned regex (per-pattern flags preserved) so the value-pattern pass is a SINGLE scan per string instead of one gsub per pattern.

Regexp.union(SECRET_VALUE_PATTERNS)
REDACTED =

The two mask shapes (rule 14). Secret-key matches keep a debug tail; PII matches and non-scalar values redact 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

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.active_configObject



31
32
33
# File 'lib/foam/otel/config.rb', line 31

def active_config
  @active_config ||= blank_config
end

Class Method Details

.add_event(name, attributes: nil) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
# File 'lib/foam/otel/api.rb', line 132

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



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

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.



232
233
234
235
236
237
238
239
# File 'lib/foam/otel/api.rb', line 232

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).



22
23
24
25
26
27
28
29
# File 'lib/foam/otel/config.rb', line 22

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

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

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



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

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

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

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



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

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

.create_ingest_span_processor(token:, environment:, redact_keys: nil, redact_pii_keys: nil, diagnostics: false) ⇒ 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.



39
40
41
42
43
44
# File 'lib/foam/otel/ingest.rb', line 39

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

.flushObject

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



203
204
205
206
207
208
209
210
211
212
213
# File 'lib/foam/otel/api.rb', line 203

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.



146
147
148
149
150
151
152
153
154
# File 'lib/foam/otel/api.rb', line 146

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) -----------------



157
158
159
# File 'lib/foam/otel/api.rb', line 157

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, additional_instrumentations: nil, additional_span_processors: nil, additional_log_record_processors: nil, additional_metric_readers: nil, ignored_outbound_hosts: nil, diagnostics: false) ⇒ Object

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



48
49
50
51
52
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
# File 'lib/foam/otel/init.rb', line 48

def init(name:, environment:, enabled:, token: nil, version: nil,
         redact_keys: nil, redact_pii_keys: nil,
         additional_instrumentations: nil,
         additional_span_processors: nil,
         additional_log_record_processors: nil,
         additional_metric_readers: nil,
         ignored_outbound_hosts: nil,
         diagnostics: false)
  # 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)
  config = resolve_config(
    name: name, environment: environment, version: version,
    enabled: effective_enabled,
    redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
    ignored_outbound_hosts: ignored_outbound_hosts,
    diagnostics: diagnostics, endpoint: endpoint
  )
  # 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, diagnostics: diagnostics
    )
    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

  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)
  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.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/foam/otel/api.rb', line 178

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.



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

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



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

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.



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

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).



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

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!
end

.resolve_config(name:, environment:, version:, enabled:, redact_keys:, redact_pii_keys:, ignored_outbound_hosts:, diagnostics:, endpoint:) ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/foam/otel/config.rb', line 37

def resolve_config(name:, environment:, version:, enabled:,
                   redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
                   diagnostics:, endpoint:)
  Config.new(
    name: name,
    environment: environment,
    version: version,
    enabled: enabled,
    # Customer lists EXTEND the always-on floor (rule 14) — stored
    # lowercased for the engine's case-insensitive substring match.
    redact_keys: downcase_list(redact_keys),
    redact_pii_keys: downcase_list(redact_pii_keys),
    ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
    diagnostics: diagnostics ? true : false,
    endpoint: endpoint
  ).freeze
end

.set_attribute(key, value) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# 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 masking (rule 18 C.3): the value lands ALREADY-MASKED,
  # so tenant processors and inert-mode providers only ever see the
  # redacted view of helper-set data. The exporter floor re-applies
  # (idempotent) — the wire value is unchanged.
  span.set_attribute(key.to_s, Redaction.mask_one(key.to_s, 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



120
121
122
123
124
125
126
127
128
129
130
# File 'lib/foam/otel/api.rb', line 120

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).



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

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.



218
219
220
221
222
223
# File 'lib/foam/otel/api.rb', line 218

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