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.
- checksums.yaml +4 -4
- data/GOTCHAS.md +522 -53
- data/README.md +341 -31
- data/RESEARCH.md +170 -70
- data/THIRD-PARTY-NOTICES +21 -10
- data/lib/foam/otel/api.rb +124 -16
- data/lib/foam/otel/classifier.rb +44 -6
- data/lib/foam/otel/config.rb +22 -2
- data/lib/foam/otel/constants.rb +15 -89
- data/lib/foam/otel/errors.rb +4 -1
- data/lib/foam/otel/fork_hooks.rb +7 -0
- data/lib/foam/otel/ingest.rb +617 -0
- data/lib/foam/otel/ingest_metric_reader.rb +87 -0
- data/lib/foam/otel/init.rb +351 -34
- data/lib/foam/otel/instance.rb +4 -1
- data/lib/foam/otel/llm/anthropic_shim.rb +84 -0
- data/lib/foam/otel/llm/gemini_shim.rb +113 -0
- data/lib/foam/otel/llm/openai_shim.rb +123 -0
- data/lib/foam/otel/llm/ruby_llm_shim.rb +131 -0
- data/lib/foam/otel/llm.rb +272 -0
- data/lib/foam/otel/logger_bridge.rb +187 -0
- data/lib/foam/otel/metrics.rb +32 -7
- data/lib/foam/otel/pipelines.rb +72 -3
- data/lib/foam/otel/redacting_exporter.rb +88 -17
- data/lib/foam/otel/redaction.rb +164 -87
- data/lib/foam/otel/runtime_metrics.rb +120 -0
- data/lib/foam/otel/session_stitching.rb +112 -0
- data/lib/foam/otel/version.rb +1 -1
- metadata +237 -19
data/lib/foam/otel/classifier.rb
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -42,8 +42,9 @@ module Foam
|
|
|
42
42
|
environment: environment,
|
|
43
43
|
version: version,
|
|
44
44
|
enabled: enabled,
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
data/lib/foam/otel/constants.rb
CHANGED
|
@@ -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
|
-
# ----
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
data/lib/foam/otel/errors.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/fork_hooks.rb
CHANGED
|
@@ -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
|