foam-otel 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,9 +14,26 @@ module Foam
14
14
  # subclasses of the proxies, so instance_of? (not is_a?) is correct. The
15
15
  # metrics-/logs-api gems must be required before their slots exist; init()
16
16
  # loads the SDK before classifying.
17
+ #
18
+ # RULE-42 FENCE (GOTCHAS F7): the Internal::Proxy* constants are
19
+ # @api-private — this reach BREAKS IF upstream renames/moves them, so it
20
+ # is (1) isolated to this one file, (2) feature-detected (`defined?`)
21
+ # degrading to a warned fail-safe no-op (UNCLASSIFIABLE, below), (3+4)
22
+ # documented in GOTCHAS F7 and pinned by spec/classifier_spec.rb's
23
+ # degradation examples, and (5) the upstream need — a public "is the
24
+ # global still the default" predicate — is tracked for filing against
25
+ # open-telemetry/opentelemetry-ruby (see GOTCHAS F7); delete the reach
26
+ # when it ships.
17
27
  Verdict = Struct.new(:free, :owner) do
18
28
  def free? = free
19
29
  def claimed? = !free
30
+
31
+ # rule 42 fence: when the API's Internal proxy constant is missing
32
+ # (upstream renamed/removed it), foam cannot PROVE the slot free. The
33
+ # verdict stays fail-safe (claimed → register nothing) but is marked
34
+ # UNCLASSIFIABLE so init warns "foam cannot classify this OTel API
35
+ # version" instead of fabricating a foreign-owner name (GOTCHAS G14).
36
+ def unclassifiable? = !free && owner.equal?(Classifier::UNCLASSIFIABLE)
20
37
  end
21
38
 
22
39
  module Classifier
@@ -24,16 +41,37 @@ module Foam
24
41
 
25
42
  SIGNALS = %i[traces metrics logs].freeze
26
43
 
44
+ # Sentinel owner for a slot whose FREE predicate is unavailable (the
45
+ # Internal proxy constant disappeared — see Verdict#unclassifiable?).
46
+ UNCLASSIFIABLE = :unclassifiable_otel_api_shape
47
+
27
48
  def classify
28
- {
29
- traces: verdict(OpenTelemetry.tracer_provider, defined?(OpenTelemetry::Internal::ProxyTracerProvider) && OpenTelemetry::Internal::ProxyTracerProvider),
30
- metrics: verdict(OpenTelemetry.meter_provider, defined?(OpenTelemetry::Internal::ProxyMeterProvider) && OpenTelemetry::Internal::ProxyMeterProvider),
31
- logs: verdict(OpenTelemetry.logger_provider, defined?(OpenTelemetry::Internal::ProxyLoggerProvider) && OpenTelemetry::Internal::ProxyLoggerProvider),
32
- }
49
+ SIGNALS.to_h { |signal| [signal, classify_signal(signal)] }
50
+ end
51
+
52
+ # One slot, read NOW. init() calls this IMMEDIATELY before each
53
+ # signal's setter — never as an up-front classify-all pass — so the
54
+ # unavoidable pre-read→set gap (Ruby has no atomic set-once attempt;
55
+ # GOTCHAS F8) stays as narrow as the platform allows.
56
+ def classify_signal(signal)
57
+ case signal
58
+ when :traces
59
+ verdict(OpenTelemetry.tracer_provider, defined?(OpenTelemetry::Internal::ProxyTracerProvider) && OpenTelemetry::Internal::ProxyTracerProvider)
60
+ when :metrics
61
+ verdict(OpenTelemetry.meter_provider, defined?(OpenTelemetry::Internal::ProxyMeterProvider) && OpenTelemetry::Internal::ProxyMeterProvider)
62
+ when :logs
63
+ verdict(OpenTelemetry.logger_provider, defined?(OpenTelemetry::Internal::ProxyLoggerProvider) && OpenTelemetry::Internal::ProxyLoggerProvider)
64
+ end
33
65
  end
34
66
 
35
67
  def verdict(provider, proxy_class)
36
- free = proxy_class ? provider.instance_of?(proxy_class) : false
68
+ # No proxy constant to test against: fail safe (never register into a
69
+ # slot foam cannot prove free) but report the DISTINCT unclassifiable
70
+ # state — a degraded verdict must never name the renamed proxy class
71
+ # as a "foreign owner" (rule 42 warned no-op, GOTCHAS G14).
72
+ return Verdict.new(false, UNCLASSIFIABLE) unless proxy_class
73
+
74
+ free = provider.instance_of?(proxy_class)
37
75
  Verdict.new(free, free ? nil : owner_name(provider))
38
76
  end
39
77
 
@@ -42,8 +42,9 @@ module Foam
42
42
  environment: environment,
43
43
  version: version,
44
44
  enabled: enabled,
45
- # Customer lists EXTEND the always-on floor (rule 14) — stored
46
- # lowercased for the engine's case-insensitive substring match.
45
+ # Opt-in redaction (holistic redesign): the ONLY keys ever redacted.
46
+ # Empty (the default) → zero redaction, raw capture. Stored lowercased
47
+ # for the engine's case-insensitive substring match.
47
48
  redact_keys: downcase_list(redact_keys),
48
49
  redact_pii_keys: downcase_list(redact_pii_keys),
49
50
  ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
@@ -57,6 +58,25 @@ module Foam
57
58
  def downcase_list(list)
58
59
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
59
60
  end
61
+
62
+ # A second init() refreshes ONLY the redaction lists (and diagnostics)
63
+ # onto the EXISTING config: enabled/endpoint/identity are process-global
64
+ # and immutable after the first init (see init.rb — a second init must
65
+ # never flip enabled and leave foam half-dark). Private: not public API
66
+ # surface (pinned by export_surface_spec).
67
+ def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:)
68
+ Config.new(
69
+ name: existing.name,
70
+ environment: existing.environment,
71
+ version: existing.version,
72
+ enabled: existing.enabled,
73
+ redact_keys: downcase_list(redact_keys),
74
+ redact_pii_keys: downcase_list(redact_pii_keys),
75
+ ignored_outbound_hosts: existing.ignored_outbound_hosts,
76
+ diagnostics: diagnostics ? true : false,
77
+ endpoint: existing.endpoint
78
+ ).freeze
79
+ end
60
80
  end
61
81
  end
62
82
  end
@@ -21,95 +21,21 @@ module Foam
21
21
  # but is never rewritten).
22
22
  KNOWN_ENVIRONMENTS = %w[production staging development test].freeze
23
23
 
24
- # ---- Secrets floor (BASE_PACKAGE_SPEC rule 14) --------------------------
25
- # RESEARCHED, not invented (provenance cited in RESEARCH.md): substring
26
- # roots curated from the consensus of OSS scrubbers — raven-ruby,
27
- # sentry-python/js, Elastic APM sanitize_field_names, is-secret,
28
- # @zapier/secret-scrubber, HyperDX, Instana, Rails filter_parameters.
29
- # Matching is SUBSTRING, case-insensitive, over a key NORMALIZED by
30
- # lowercasing and folding `-` `_` `.` and space (so `X-Api-Key` ≡
31
- # `api_key` ≡ `apiKey`).
32
- #
33
- # BARE_SECRET_ROOTS are separator-free and matched against the normalized
34
- # key. They are distinctive enough to never over-redact.
35
- BARE_SECRET_ROOTS = %w[
36
- secret
37
- token
38
- password
39
- passwd
40
- pwd
41
- passphrase
42
- apikey
43
- secretkey
44
- accesskey
45
- privatekey
46
- credential
47
- credentials
48
- session
49
- phpsessid
50
- jsessionid
51
- connectsid
52
- aspnetsessionid
53
- cookie
54
- csrf
55
- xsrf
56
- jwt
57
- bearer
58
- signature
59
- mysqlpwd
60
- proxyauthorization
61
- crypt
62
- creditcard
63
- cardnumber
64
- ].freeze
65
-
66
- # GUARDED roots are short/ambiguous — matched with word boundaries on the
67
- # lowercased key (separators kept) so they never OVER-redact (lost
68
- # observability, not extra safety). `key`/`sid`/`id`/`salt` are REJECTED
69
- # as roots entirely: bare `key` eats idempotency_key/cache_key/public_key;
70
- # bare `sid` eats consider/reside/president. `auth` must miss author;
71
- # `card` must miss dashboard/wildcard/postcard.
72
- GUARDED_SECRET_ROOTS = %w[ssn otp mfa 2fa sso saml cvv cvc dni].freeze
73
-
74
- # Precompiled once at load: one token-boundary regex matching ANY guarded
75
- # root, so the hot path never recompiles an interpolated regex per key.
76
- GUARDED_SECRET_RE =
77
- /(?<![a-z0-9])(?:#{GUARDED_SECRET_ROOTS.map { |r| Regexp.escape(r) }.join('|')})(?![a-z0-9])/
78
-
79
- # ---- Value patterns (rule 14 second pass) ------------------------------
80
- # A pass over string VALUES/bodies so a secret under an UNLISTED key still
81
- # masks. \b-bounded (usable both whole-value and embedded), prefix-
82
- # anchored, low false-positive vendor key shapes catalogued by OSS secret
83
- # scanners (detect-secrets Apache-2.0, secretlint MIT, gitleaks MIT).
84
- # Bare high-entropy / plain 32-hex / base64 / credit-card-Luhn are
85
- # deliberately OMITTED (opt-in only — they destroy observability without a
86
- # nearby keyword). Cited in RESEARCH.md.
87
- SECRET_VALUE_PATTERNS = [
88
- %r{\b(?:Bearer|Basic)\s+[A-Za-z0-9\-._~+/=]{8,}}i, # auth header values
89
- /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/, # JWT
90
- /\b(?:sk|pk|rk)[-_](?:ant-)?[A-Za-z0-9_-]{16,}\b/, # OpenAI/Anthropic/generic
91
- /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/, # Stripe live
92
- /\bgh[pousr]_[A-Za-z0-9]{16,}\b/, # GitHub tokens
93
- /\bgithub_pat_[A-Za-z0-9_]{16,}\b/, # GitHub fine-grained PAT
94
- /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, # Slack
95
- /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/, # AWS access key id
96
- /\bAIza[0-9A-Za-z\-_]{20,}\b/, # Google API key
97
- /\bglpat-[A-Za-z0-9\-_]{16,}\b/, # GitLab PAT
98
- /\bnpm_[A-Za-z0-9]{16,}\b/, # npm
99
- /\bpypi-[A-Za-z0-9\-_]{16,}\b/, # PyPI
100
- /\bhf_[A-Za-z0-9]{16,}\b/, # HuggingFace
101
- /\bshp(?:ss|at|ca|pa|a)_[A-Za-z0-9]{16,}\b/, # Shopify
102
- /\bdapi[a-f0-9]{32}\b/i, # Databricks
103
- /-----BEGIN[A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END[A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----/, # PEM
104
- ].freeze
105
-
106
- # One unioned regex (per-pattern flags preserved) so the value-pattern pass
107
- # is a SINGLE scan per string instead of one gsub per pattern.
108
- SECRET_VALUE_RE = Regexp.union(SECRET_VALUE_PATTERNS)
109
-
110
- # The two mask shapes (rule 14). Secret-key matches keep a debug tail;
111
- # PII matches and non-scalar values redact in full. MASK_BODY matches
112
- # raven-ruby's STRING_MASK precedent.
24
+ # ---- Redaction is FULLY OPT-IN (holistic redesign, 2026-07-26) ----------
25
+ # The package does NO redaction by default: with neither redact_keys nor
26
+ # redact_pii_keys set (or both empty) every value is captured RAW,
27
+ # including secrets / PII / PHI. There is NO always-on secret floor and NO
28
+ # value-pattern auto-masking — the package auto-masks NOTHING. Redaction
29
+ # happens ONLY for the exact keys the customer enumerates:
30
+ # * redact_keys → tail-mask the value (shape-preserving; see below)
31
+ # * redact_pii_keys → erase the value ([REDACTED])
32
+ # (See docs/decisions/holistic-redesign-decision-log.md — "Redaction is
33
+ # FULLY OPT-IN".) The customer owns the privacy/legal posture.
34
+
35
+ # The two mask shapes. A redact_keys match on a scalar keeps a debug tail;
36
+ # a redact_pii_keys match (and any non-scalar value under a redact_keys
37
+ # match) erases in full. MASK_BODY matches raven-ruby's STRING_MASK
38
+ # precedent.
113
39
  REDACTED = "[REDACTED]"
114
40
  MASK_BODY = "********" # always exactly eight chars — never length-revealing
115
41
  TAIL_MIN_LENGTH = 12 # below this, no tail is safe
@@ -35,7 +35,10 @@ module Foam
35
35
  span.record_exception(error)
36
36
  span.status = OpenTelemetry::Trace::Status.error(error.message.to_s)
37
37
  true
38
- rescue StandardError
38
+ rescue StandardError, SystemStackError
39
+ # SystemStackError is not a StandardError: a customer error whose
40
+ # #message/#to_s recurses unboundedly must never turn "recording an
41
+ # exception" into a host-thread crash (rule 9).
39
42
  false
40
43
  end
41
44
  end
@@ -35,6 +35,13 @@ module Foam
35
35
  if pid.zero?
36
36
  begin
37
37
  Foam::Otel.after_fork!
38
+ # Let a per-worker init (the README on_worker_boot recipe) be a
39
+ # REAL re-init in the child: without this, the inherited
40
+ # @initialized guard returns the PARENT's instance and every
41
+ # worker reports the parent's service.instance.id (section 0
42
+ # resource table / GOTCHAS R1). Workers that never re-init keep
43
+ # the inherited pipelines (readers restarted above).
44
+ Foam::Otel.send(:mark_forked!)
38
45
  rescue StandardError
39
46
  nil
40
47
  end