foam-otel 1.4.0 → 1.5.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/README.md +74 -3
- data/lib/foam/otel/config.rb +85 -10
- data/lib/foam/otel/constants.rb +69 -0
- data/lib/foam/otel/ingest.rb +21 -8
- data/lib/foam/otel/init.rb +6 -3
- data/lib/foam/otel/redaction.rb +142 -7
- data/lib/foam/otel/version.rb +16 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e23dd37962a70ea36799a92d914fd2a38aa967a6cd1aface19bbaa322ab8966
|
|
4
|
+
data.tar.gz: f0018c27fb2dfff9316118438688a4a8ab2baea18810dbda522880361dfe6681
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9731cb1fae1b54f236dd583102b2348ab4b45bff38745f446c584151e594ea685f2689d6a7fb4807136372006b7b10c0284b2e7c140c0c9625edb5a820294c8a
|
|
7
|
+
data.tar.gz: 91744d9fb0fb671bce62de973eec96152b147742ee5948fedf607626dfd89b8c3c6ed055522f21c4bbfd7d65ce5b558da9e67605578e3573ec15f8b0d4efeecc
|
data/README.md
CHANGED
|
@@ -142,8 +142,9 @@ API):** the allowlist must include BOTH headers —
|
|
|
142
142
|
| `enabled:` | Boolean | yes | — | The config switch. `false` = fully inert (no SDK, no providers, no network, helpers no-op). `true` = export, in EVERY environment. No default — you write the logic. |
|
|
143
143
|
| `token:` | String | when enabled | — | `Authorization: Bearer` for export. Required (and validated) only when `enabled: true`; wired explicitly from your secret source — never an env fallback read by the gem. **Fleet convention: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
|
|
144
144
|
| `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
145
|
+
| `redact:` | Hash | no | nil | The grouped redaction object (1.5.0, the preferred surface): `{ secrets: [...], pii: [...], detect: [...] }` — symbol or string keys. `secrets:` has `redact_keys` semantics (tail mask) and `pii:` has `redact_pii_keys` semantics (full `[REDACTED]`); when both a legacy option and its `redact` field are set, the lists UNION. `detect:` is the opt-in PII detection tier's entity list — value-shape detection with typed placeholders (see "PII detection (opt-in)" below). An unknown field, a non-Array value, or an unknown `detect` entity name raises at boot. Absent (the default) → behavior byte-identical to before. |
|
|
146
|
+
| `redact_keys:` | Array<String> | no | `[]` | Alias — the preferred spelling is `redact: { secrets: [...] }` (same semantics; the lists union when both are set). The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in and ADDITIVE on top of the always-on credential floor — empty (the default) means no masking beyond the floor. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. A key that is also on the floor stays fully `[REDACTED]` (the floor wins; it is never downgraded to a tail). |
|
|
147
|
+
| `redact_pii_keys:` | Array<String> | no | `[]` | Alias — the preferred spelling is `redact: { pii: [...] }` (same semantics; the lists union when both are set). Field names fully erased to `[REDACTED]` (no tail). Opt-in and additive above the floor — empty (the default) means no erasure beyond the floor. Foam ships no PII preset; this is your enumeration. |
|
|
147
148
|
| `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
|
|
148
149
|
| `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
|
|
149
150
|
| `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
|
|
@@ -161,6 +162,22 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
|
|
|
161
162
|
redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
|
|
162
163
|
```
|
|
163
164
|
|
|
165
|
+
```ruby
|
|
166
|
+
# The grouped redact object (1.5.0, preferred — the flat options above are
|
|
167
|
+
# aliases and UNION with it when both are set), plus the opt-in PII
|
|
168
|
+
# detection tier (typed placeholders; see "PII detection (opt-in)"):
|
|
169
|
+
Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
|
|
170
|
+
redact: {
|
|
171
|
+
secrets: %w[internal_ref voucher_code], # == redact_keys (tail mask)
|
|
172
|
+
pii: %w[customer_email full_name], # == redact_pii_keys (full [REDACTED])
|
|
173
|
+
detect: %w[email phone ssn credit_card ip], # value-shape detection → [EMAIL], [PHONE], …
|
|
174
|
+
})
|
|
175
|
+
# An unknown field, a non-Array value, or an unknown detect entity raises at boot:
|
|
176
|
+
# Foam::Otel.init(..., redact: { detect: ["name"] })
|
|
177
|
+
# => raises ArgumentError: Foam::Otel.init redact: unknown detect entity "name" — the valid
|
|
178
|
+
# entity names are exactly {email, phone, ssn, credit_card, ip} (contract/pii-detect.json)
|
|
179
|
+
```
|
|
180
|
+
|
|
164
181
|
```ruby
|
|
165
182
|
# Only if your legitimate telemetry collides with the generic secret
|
|
166
183
|
# heuristics (e.g. base64 content-addressed ids masked as high-entropy
|
|
@@ -365,6 +382,58 @@ changed only by fleet ruling.
|
|
|
365
382
|
|
|
366
383
|
---
|
|
367
384
|
|
|
385
|
+
## PII detection (opt-in)
|
|
386
|
+
|
|
387
|
+
The floor and the secret layer protect credentials. PII in VALUES — an email
|
|
388
|
+
address inside a log line, a card number inside an exception message — still
|
|
389
|
+
exports RAW by default, because coverage-over-masking is the mission and only
|
|
390
|
+
you know your privacy posture. As of 1.5.0 you can opt into value-shape PII
|
|
391
|
+
detection per entity, via `redact: { detect: [...] }` (fleet ruling
|
|
392
|
+
2026-07-28; frozen fixture: `contract/pii-detect.json` — the entity names and
|
|
393
|
+
placeholders are byte-identical in every foam core and gate-checked in CI by
|
|
394
|
+
`spec/pii_detect_spec.rb`). Nothing detects unless you list the entity —
|
|
395
|
+
an empty/absent `detect` list is exactly today's behavior.
|
|
396
|
+
|
|
397
|
+
**The exact entity set** (these five, nothing else — an unknown name raises
|
|
398
|
+
at boot):
|
|
399
|
+
|
|
400
|
+
| Entity | Placeholder | Catches |
|
|
401
|
+
| --- | --- | --- |
|
|
402
|
+
| `email` | `[EMAIL]` | `john.smith+test@example.co.uk` — requires a real TLD (`user@localhost` rides raw) |
|
|
403
|
+
| `phone` | `[PHONE]` | `+1 (415) 555-0142`, `415-555-0199` — separated groups; bare digit runs, dotted versions (`2024.10.05`) and clock times ride raw |
|
|
404
|
+
| `ssn` | `[SSN]` | `536-90-4399`, `536 90 4399` — delimited 3-2-4 only; structurally invalid SSNs (`000-…`, group `00`, serial `0000`) and undelimited runs ride raw |
|
|
405
|
+
| `credit_card` | `[CREDIT_CARD]` | 13–19 digit PANs (spaced/dashed/contiguous) that pass **Luhn** — a card-shaped tracking id failing the checksum is never masked |
|
|
406
|
+
| `ip` | `[IP]` | IPv4 (octet-validated — `999.1.1.1` and `10.4.1.2000` ride raw) and IPv6, `::`-compressed included |
|
|
407
|
+
|
|
408
|
+
**How it masks.** Only the matched character span is replaced with the
|
|
409
|
+
entity's typed placeholder — surrounding text keeps its diagnostic value
|
|
410
|
+
(`login from [IP] flagged`). It runs everywhere the value-pattern secret
|
|
411
|
+
layer runs (span/event/link attributes, `status.message`, log bodies, URL
|
|
412
|
+
query and fragment leaves, nested structures, both doors), immediately AFTER
|
|
413
|
+
the secret layer (a shaped credential still becomes `[REDACTED]` — detect
|
|
414
|
+
never weakens it) and BEFORE your key lists; metric datapoint attributes stay
|
|
415
|
+
exempt exactly like the secret layer. Detection is idempotent (placeholders
|
|
416
|
+
never re-match), rides the same execution caps and fail-closed discipline
|
|
417
|
+
(any detector fault masks the whole value to `[REDACTED]`, loudly), and the
|
|
418
|
+
patterns follow the same bounded-quantifier authoring rules.
|
|
419
|
+
|
|
420
|
+
**What it can NOT do — read this before relying on it.** This is value-SHAPE
|
|
421
|
+
detection, nothing more: **person names and free-text prose are NOT
|
|
422
|
+
detectable** — recognizing "the patient, John Smith, reported…" takes
|
|
423
|
+
server-side NER, which is platform scope, not an SDK regex. Field-NAME-keyed
|
|
424
|
+
PII (`patient_name`) is what `redact: { pii: [...] }` is for; enumerate those
|
|
425
|
+
keys yourself. Foam still ships no PII preset — the detect list is your
|
|
426
|
+
explicit, audited enumeration.
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
# Door-2 taps take the same redact object, scoped to that tap:
|
|
430
|
+
processor = Foam::Otel.create_ingest_span_processor(
|
|
431
|
+
token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV"),
|
|
432
|
+
redact: { pii: %w[customer_email], detect: %w[email credit_card] })
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
368
437
|
## The helpers
|
|
369
438
|
|
|
370
439
|
All helpers never raise, and no-op silently before `init` and when disabled.
|
|
@@ -607,7 +676,9 @@ readers only: they never mutate their data, their resource, or their export.
|
|
|
607
676
|
### The three entries
|
|
608
677
|
|
|
609
678
|
All three share one signature (`token:` and `environment:` required; the
|
|
610
|
-
redaction kwargs
|
|
679
|
+
redaction kwargs — the grouped `redact:` object and its legacy aliases
|
|
680
|
+
`redact_keys:`/`redact_pii_keys:`, union semantics exactly as on `init` —
|
|
681
|
+
are the only CUSTOMER redaction that tap applies — opt-in, none by
|
|
611
682
|
default, scoped to that tap, additive above the always-on credential floor,
|
|
612
683
|
which every tap applies with zero configuration; `diagnostics:` is tap-scoped
|
|
613
684
|
narration). Construction
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -11,11 +11,17 @@ module Foam
|
|
|
11
11
|
# loop guard and the redacting exporters can read one authoritative value.
|
|
12
12
|
Config = Struct.new(
|
|
13
13
|
:name, :environment, :version, :enabled,
|
|
14
|
-
:redact_keys, :redact_pii_keys, :ignored_outbound_hosts,
|
|
14
|
+
:redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
|
|
15
15
|
:diagnostics, :endpoint, :secret_heuristics,
|
|
16
16
|
keyword_init: true
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
# The exact field names the grouped `redact:` option accepts (fleet ruling
|
|
20
|
+
# 2026-07-28, docs/decisions/redact-object-detect-design.md §2): `secrets`
|
|
21
|
+
# unions with the legacy redact_keys, `pii` with redact_pii_keys, `detect`
|
|
22
|
+
# opts into the PII detection tier. Anything else raises at boot.
|
|
23
|
+
REDACT_OPTION_FIELDS = %w[secrets pii detect].freeze
|
|
24
|
+
|
|
19
25
|
class << self
|
|
20
26
|
# The inert config the helpers read before init() runs (everything a
|
|
21
27
|
# no-op needs: empty redaction lists, export disabled).
|
|
@@ -23,6 +29,7 @@ module Foam
|
|
|
23
29
|
Config.new(
|
|
24
30
|
name: "", environment: "", version: nil, enabled: false,
|
|
25
31
|
redact_keys: [].freeze, redact_pii_keys: [].freeze,
|
|
32
|
+
redact_detect: [].freeze,
|
|
26
33
|
ignored_outbound_hosts: [].freeze,
|
|
27
34
|
diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
|
|
28
35
|
secret_heuristics: true
|
|
@@ -37,7 +44,8 @@ module Foam
|
|
|
37
44
|
|
|
38
45
|
def resolve_config(name:, environment:, version:, enabled:,
|
|
39
46
|
redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
|
|
40
|
-
diagnostics:, endpoint:, secret_heuristics: true)
|
|
47
|
+
diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
|
|
48
|
+
keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
|
|
41
49
|
Config.new(
|
|
42
50
|
name: name,
|
|
43
51
|
environment: environment,
|
|
@@ -47,9 +55,13 @@ module Foam
|
|
|
47
55
|
# always-on credential floor (which is a module constant, NOT config
|
|
48
56
|
# state — no config shape can alter it). Empty (the default) → no
|
|
49
57
|
# customer redaction, raw capture above the floor. Stored lowercased
|
|
50
|
-
# for the engine's case-insensitive substring match.
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
# for the engine's case-insensitive substring match. The grouped
|
|
59
|
+
# `redact:` object (2026-07-28 ruling) UNIONS with the legacy flat
|
|
60
|
+
# lists; redact.detect is the opt-in PII detection tier's entity
|
|
61
|
+
# list — empty (the default) means zero PII detection.
|
|
62
|
+
redact_keys: keys,
|
|
63
|
+
redact_pii_keys: pii,
|
|
64
|
+
redact_detect: detect,
|
|
53
65
|
ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
|
|
54
66
|
diagnostics: diagnostics ? true : false,
|
|
55
67
|
endpoint: endpoint,
|
|
@@ -69,21 +81,84 @@ module Foam
|
|
|
69
81
|
Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
|
|
70
82
|
end
|
|
71
83
|
|
|
84
|
+
# ---- the grouped `redact:` object (fleet ruling 2026-07-28) -----------
|
|
85
|
+
# Parse + validate the object into its three raw lists. LOUD AT BOOT
|
|
86
|
+
# (rule 10, exactly like the name/environment validation): a non-Hash
|
|
87
|
+
# redact, an unknown field, a non-Array field value, or an unknown
|
|
88
|
+
# detect entity name raises ArgumentError here — never a silent skip,
|
|
89
|
+
# never a dark boot. Symbol AND string keys/entities are accepted
|
|
90
|
+
# (design §2 Ruby row); entities are coerced with the same downcase
|
|
91
|
+
# normalization the legacy lists use, then validated against the frozen
|
|
92
|
+
# fixture entity names (contract/pii-detect.json — the ONLY valid
|
|
93
|
+
# spellings). Called by both doors: init() via resolve_config /
|
|
94
|
+
# refresh_post_init_config, and the ingest factory's step-2 validation
|
|
95
|
+
# (with the entry name as context).
|
|
96
|
+
def parse_redact!(redact, context: "Foam::Otel.init")
|
|
97
|
+
return { secrets: nil, pii: nil, detect: nil } if redact.nil?
|
|
98
|
+
|
|
99
|
+
unless redact.is_a?(Hash)
|
|
100
|
+
raise ArgumentError, "#{context} redact: must be a Hash with fields drawn from " \
|
|
101
|
+
"{#{REDACT_OPTION_FIELDS.join(', ')}} (e.g. redact: { detect: [\"email\"] }), " \
|
|
102
|
+
"got #{redact.class}"
|
|
103
|
+
end
|
|
104
|
+
out = { secrets: nil, pii: nil, detect: nil }
|
|
105
|
+
redact.each do |key, value|
|
|
106
|
+
field = key.to_s
|
|
107
|
+
unless REDACT_OPTION_FIELDS.include?(field)
|
|
108
|
+
raise ArgumentError, "#{context} redact: unknown field #{key.inspect} — the valid fields are " \
|
|
109
|
+
"exactly {#{REDACT_OPTION_FIELDS.join(', ')}}"
|
|
110
|
+
end
|
|
111
|
+
unless value.is_a?(Array)
|
|
112
|
+
raise ArgumentError, "#{context} redact: #{field} must be an Array of strings, got #{value.class}"
|
|
113
|
+
end
|
|
114
|
+
out[field.to_sym] = Array(out[field.to_sym]) + value
|
|
115
|
+
end
|
|
116
|
+
validate_detect_entities!(out[:detect], context)
|
|
117
|
+
out
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Fixture rule "unknown_entity": the valid names are exactly the keys of
|
|
121
|
+
# contract/pii-detect.json's `entities` — anything else raises at init.
|
|
122
|
+
def validate_detect_entities!(detect, context)
|
|
123
|
+
return if detect.nil?
|
|
124
|
+
|
|
125
|
+
valid = PII_DETECT_ENTITIES.keys
|
|
126
|
+
detect.each do |entity|
|
|
127
|
+
next if valid.include?(entity.to_s.downcase)
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, "#{context} redact: unknown detect entity #{entity.inspect} — the valid entity " \
|
|
130
|
+
"names are exactly {#{valid.join(', ')}} (contract/pii-detect.json)"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Legacy flat lists ∪ the grouped object's fields (design §2: the legacy
|
|
135
|
+
# aliases live on unchanged; when both are set, the lists UNION), all
|
|
136
|
+
# three normalized exactly as the legacy options always were.
|
|
137
|
+
def merged_redact_lists(redact_keys, redact_pii_keys, redact, context: "Foam::Otel.init")
|
|
138
|
+
parsed = parse_redact!(redact, context: context)
|
|
139
|
+
[downcase_list(Array(redact_keys) + Array(parsed[:secrets])),
|
|
140
|
+
downcase_list(Array(redact_pii_keys) + Array(parsed[:pii])),
|
|
141
|
+
downcase_list(parsed[:detect])]
|
|
142
|
+
end
|
|
143
|
+
|
|
72
144
|
# A second init() refreshes ONLY the redaction lists (and diagnostics,
|
|
73
145
|
# and the heuristic-tier flag — redaction state) onto the EXISTING
|
|
74
146
|
# config: enabled/endpoint/identity are process-global and immutable
|
|
75
147
|
# after the first init (see init.rb — a second init must never flip
|
|
76
|
-
# enabled and leave foam half-dark).
|
|
77
|
-
# (
|
|
148
|
+
# enabled and leave foam half-dark). The `redact:` object refreshes
|
|
149
|
+
# exactly like the legacy lists (design §2 second-init row). Private:
|
|
150
|
+
# not public API surface (pinned by export_surface_spec).
|
|
78
151
|
def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:,
|
|
79
|
-
secret_heuristics: true)
|
|
152
|
+
secret_heuristics: true, redact: nil)
|
|
153
|
+
keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
|
|
80
154
|
Config.new(
|
|
81
155
|
name: existing.name,
|
|
82
156
|
environment: existing.environment,
|
|
83
157
|
version: existing.version,
|
|
84
158
|
enabled: existing.enabled,
|
|
85
|
-
redact_keys:
|
|
86
|
-
redact_pii_keys:
|
|
159
|
+
redact_keys: keys,
|
|
160
|
+
redact_pii_keys: pii,
|
|
161
|
+
redact_detect: detect,
|
|
87
162
|
ignored_outbound_hosts: existing.ignored_outbound_hosts,
|
|
88
163
|
diagnostics: diagnostics ? true : false,
|
|
89
164
|
endpoint: existing.endpoint,
|
data/lib/foam/otel/constants.rb
CHANGED
|
@@ -342,5 +342,74 @@ module Foam
|
|
|
342
342
|
yourkey your_api_key your-api-key xxxxxxxx todo
|
|
343
343
|
notreal loremipsum deadbeef 0000000000 1111111111
|
|
344
344
|
].freeze
|
|
345
|
+
|
|
346
|
+
# ---- THE PII DETECT TIER (fleet ruling 2026-07-28, FULLY OPT-IN) --------
|
|
347
|
+
# docs/decisions/redact-object-detect-design.md §3; frozen fixture:
|
|
348
|
+
# contract/pii-detect.json (entity names + placeholders byte-identical,
|
|
349
|
+
# gate-checked by spec/pii_detect_spec.rb; behavior proven against the
|
|
350
|
+
# fixture's positive/negative vectors). Nothing here runs unless the
|
|
351
|
+
# customer lists the entity in init's redact.detect (or a door-2 tap's
|
|
352
|
+
# redact:) — the empty/absent list is today's raw-by-default behavior,
|
|
353
|
+
# byte-identical. The engine (redaction.rb detect_pii) runs this tier
|
|
354
|
+
# immediately AFTER the value-pattern secret layer and BEFORE customer
|
|
355
|
+
# key masking, masking ONLY the matched character span with the entity's
|
|
356
|
+
# typed :placeholder (surrounding text preserved verbatim; emitted
|
|
357
|
+
# placeholders never re-match — fixture "placeholder_idempotence").
|
|
358
|
+
#
|
|
359
|
+
# Authoring rules mirror the secret layer's (design §V.5.2 / rule 49):
|
|
360
|
+
# compile-once at require (a bad pattern fails the require, loudly),
|
|
361
|
+
# bounded quantifiers only, no lookbehind, no atomic/possessive groups;
|
|
362
|
+
# :group names the span to mask (0 = the whole match; phone masks group 1
|
|
363
|
+
# behind a consumed non-digit boundary — never a lookbehind). Patterns
|
|
364
|
+
# are byte-consistent with the python core's PII_DETECT_ENTITIES; the one
|
|
365
|
+
# recorded portability translation is phone's `^` → `\A` (Ruby's ^ is a
|
|
366
|
+
# LINE anchor — same translation note as SECRET_HEURISTIC_H2). :filter
|
|
367
|
+
# (:luhn) names a mandatory post-match validator — a card-shaped digit
|
|
368
|
+
# string failing Luhn is NEVER masked (fixture rule "checksum"). NOT
|
|
369
|
+
# detectable by design: person names and free-text prose (server-side
|
|
370
|
+
# NER scope — the README states this plainly).
|
|
371
|
+
PII_DETECT_ENTITIES = {
|
|
372
|
+
"email" => {
|
|
373
|
+
placeholder: "[EMAIL]", group: 0,
|
|
374
|
+
regex: '\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9\-]{1,63}' \
|
|
375
|
+
'(?:\.[A-Za-z0-9\-]{1,63}){0,10}\.[A-Za-z]{2,24}\b',
|
|
376
|
+
}.freeze,
|
|
377
|
+
# Group 1 is the number; the leading alternation is a consumed boundary
|
|
378
|
+
# guard (no lookbehind — rule-49 discipline). Separators are space/dash
|
|
379
|
+
# only, so dotted versions (2024.10.05) and clock times (12:30:45)
|
|
380
|
+
# never trip.
|
|
381
|
+
"phone" => {
|
|
382
|
+
placeholder: "[PHONE]", group: 1,
|
|
383
|
+
regex: '(?:\A|[^0-9A-Za-z])((?:\+[0-9]{1,3}[ \-]?)?' \
|
|
384
|
+
'(?:\([0-9]{1,4}\)[ \-]?|[0-9]{1,4}[ \-])' \
|
|
385
|
+
'[0-9]{3,4}[ \-][0-9]{3,4})(?![0-9])',
|
|
386
|
+
}.freeze,
|
|
387
|
+
# Delimited 3-2-4 with a CONSISTENT delimiter (the \1 backreference —
|
|
388
|
+
# a one-char group, linear by construction) — an undelimited 9-digit
|
|
389
|
+
# run never matches. Area 000/666/9xx, group 00, and serial 0000 are
|
|
390
|
+
# structurally invalid SSNs and ride raw.
|
|
391
|
+
"ssn" => {
|
|
392
|
+
placeholder: "[SSN]", group: 0,
|
|
393
|
+
regex: '\b(?!000|666|9[0-9]{2})[0-9]{3}([ \-])' \
|
|
394
|
+
'(?!00)[0-9]{2}\1(?!0000)[0-9]{4}\b',
|
|
395
|
+
}.freeze,
|
|
396
|
+
# 13-19 digits in 4-4-4-rest grouping (space/dash/contiguous). The
|
|
397
|
+
# regex is deliberately loose on grouping; the LUHN filter is the
|
|
398
|
+
# normative gate (fixture "checksum" rule).
|
|
399
|
+
"credit_card" => {
|
|
400
|
+
placeholder: "[CREDIT_CARD]", group: 0, filter: :luhn,
|
|
401
|
+
regex: '\b[0-9]{4}(?:[ \-]?[0-9]{4}){2}[ \-]?[0-9]{1,7}\b',
|
|
402
|
+
}.freeze,
|
|
403
|
+
# IPv4 with per-octet range validation (999.1.1.1 and 4-digit octets
|
|
404
|
+
# ride raw), full 8-group IPv6, and ::-compressed IPv6.
|
|
405
|
+
"ip" => {
|
|
406
|
+
placeholder: "[IP]", group: 0,
|
|
407
|
+
regex: '\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}' \
|
|
408
|
+
'(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b' \
|
|
409
|
+
'|\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b' \
|
|
410
|
+
'|\b(?:[0-9A-Fa-f]{1,4}:){1,6}:' \
|
|
411
|
+
'(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?(?![0-9A-Fa-f:])',
|
|
412
|
+
}.freeze,
|
|
413
|
+
}.freeze
|
|
345
414
|
end
|
|
346
415
|
end
|
data/lib/foam/otel/ingest.rb
CHANGED
|
@@ -38,31 +38,38 @@ module Foam
|
|
|
38
38
|
# Door-2 traces entry: a SpanProcessor-duck instance for THEIR
|
|
39
39
|
# TracerProvider (their setup block / add_span_processor). Foam's copy
|
|
40
40
|
# is stamped + masked at export; their spans are never touched.
|
|
41
|
+
# `redact:` is the grouped redaction object (fleet ruling 2026-07-28,
|
|
42
|
+
# same shape and union semantics as init's — secrets ∪ redact_keys,
|
|
43
|
+
# pii ∪ redact_pii_keys, detect = the opt-in PII detection tier),
|
|
44
|
+
# scoped to this tap like the legacy kwargs.
|
|
41
45
|
def create_ingest_span_processor(token:, environment:, redact_keys: nil,
|
|
42
|
-
redact_pii_keys: nil, diagnostics: false,
|
|
46
|
+
redact_pii_keys: nil, redact: nil, diagnostics: false,
|
|
43
47
|
secret_heuristics: true)
|
|
44
48
|
Ingest.build(:traces, token: token, environment: environment,
|
|
45
49
|
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
|
|
46
|
-
|
|
50
|
+
redact: redact, diagnostics: diagnostics,
|
|
51
|
+
secret_heuristics: secret_heuristics)
|
|
47
52
|
end
|
|
48
53
|
|
|
49
54
|
# Door-2 logs entry: a LogRecordProcessor duck for THEIR LoggerProvider.
|
|
50
55
|
def create_ingest_log_record_processor(token:, environment:, redact_keys: nil,
|
|
51
|
-
redact_pii_keys: nil, diagnostics: false,
|
|
56
|
+
redact_pii_keys: nil, redact: nil, diagnostics: false,
|
|
52
57
|
secret_heuristics: true)
|
|
53
58
|
Ingest.build(:logs, token: token, environment: environment,
|
|
54
59
|
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
|
|
55
|
-
|
|
60
|
+
redact: redact, diagnostics: diagnostics,
|
|
61
|
+
secret_heuristics: secret_heuristics)
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
# Door-2 metrics entry: a PeriodicMetricReader subclass for THEIR
|
|
59
65
|
# MeterProvider#add_metric_reader (the back-filling attach point).
|
|
60
66
|
def create_ingest_metric_reader(token:, environment:, redact_keys: nil,
|
|
61
|
-
redact_pii_keys: nil, diagnostics: false,
|
|
67
|
+
redact_pii_keys: nil, redact: nil, diagnostics: false,
|
|
62
68
|
secret_heuristics: true)
|
|
63
69
|
Ingest.build(:metrics, token: token, environment: environment,
|
|
64
70
|
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
|
|
65
|
-
|
|
71
|
+
redact: redact, diagnostics: diagnostics,
|
|
72
|
+
secret_heuristics: secret_heuristics)
|
|
66
73
|
end
|
|
67
74
|
end
|
|
68
75
|
|
|
@@ -112,7 +119,7 @@ module Foam
|
|
|
112
119
|
|
|
113
120
|
class << self
|
|
114
121
|
def build(signal, token:, environment:, redact_keys:, redact_pii_keys:, diagnostics:,
|
|
115
|
-
secret_heuristics: true)
|
|
122
|
+
secret_heuristics: true, redact: nil)
|
|
116
123
|
# (1) the operator kill switch — read once at construction, exactly
|
|
117
124
|
# like init (rule 19 / fleet audit R13). Trimmed + case-insensitive
|
|
118
125
|
# so the fleet's one emergency switch behaves the same across
|
|
@@ -130,6 +137,12 @@ module Foam
|
|
|
130
137
|
entry = ENTRY_NAMES.fetch(signal)
|
|
131
138
|
validate_present!(entry, :token, token)
|
|
132
139
|
validate_present!(entry, :environment, environment)
|
|
140
|
+
# The grouped `redact:` object validates HERE, in the loud step-2
|
|
141
|
+
# block (fleet ruling 2026-07-28, same rule-10 posture as init): an
|
|
142
|
+
# unknown field, a non-Array value, or an unknown detect entity
|
|
143
|
+
# raises ArgumentError at construction — never a degraded-to-inert
|
|
144
|
+
# boot the rescue below would swallow.
|
|
145
|
+
Foam::Otel.send(:parse_redact!, redact, context: "[foam] #{entry}")
|
|
133
146
|
Foam::Otel.send(:warn_environment, environment)
|
|
134
147
|
|
|
135
148
|
# (3) build. After validation NOTHING ever throws (rule 9): a broken
|
|
@@ -146,7 +159,7 @@ module Foam
|
|
|
146
159
|
Foam::Otel.send(:warn_inert_per_signal_endpoints, endpoint)
|
|
147
160
|
config = Foam::Otel.resolve_config(
|
|
148
161
|
name: "", environment: environment.to_s, version: nil, enabled: true,
|
|
149
|
-
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
|
|
162
|
+
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, redact: redact,
|
|
150
163
|
ignored_outbound_hosts: [], diagnostics: diagnostics ? true : false,
|
|
151
164
|
endpoint: endpoint, secret_heuristics: secret_heuristics
|
|
152
165
|
)
|
data/lib/foam/otel/init.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Foam
|
|
|
49
49
|
class << self
|
|
50
50
|
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
51
51
|
def init(name:, environment:, enabled:, token: nil, version: nil,
|
|
52
|
-
redact_keys: nil, redact_pii_keys: nil,
|
|
52
|
+
redact_keys: nil, redact_pii_keys: nil, redact: nil,
|
|
53
53
|
additional_instrumentations: nil,
|
|
54
54
|
additional_span_processors: nil,
|
|
55
55
|
additional_log_record_processors: nil,
|
|
@@ -78,10 +78,13 @@ module Foam
|
|
|
78
78
|
|
|
79
79
|
endpoint = resolve_endpoint
|
|
80
80
|
warn_inert_per_signal_endpoints(endpoint)
|
|
81
|
+
# The grouped `redact:` object (fleet ruling 2026-07-28) resolves here
|
|
82
|
+
# too — an unknown field, a non-Array value, or an unknown detect
|
|
83
|
+
# entity raises INSIDE resolve_config, loudly, at boot (rule 10).
|
|
81
84
|
config = resolve_config(
|
|
82
85
|
name: name, environment: environment, version: version,
|
|
83
86
|
enabled: effective_enabled,
|
|
84
|
-
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
|
|
87
|
+
redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, redact: redact,
|
|
85
88
|
ignored_outbound_hosts: ignored_outbound_hosts,
|
|
86
89
|
diagnostics: diagnostics, endpoint: endpoint,
|
|
87
90
|
secret_heuristics: secret_heuristics
|
|
@@ -105,7 +108,7 @@ module Foam
|
|
|
105
108
|
if @initialized
|
|
106
109
|
self.active_config = refresh_post_init_config(
|
|
107
110
|
active_config, redact_keys: redact_keys, redact_pii_keys: redact_pii_keys,
|
|
108
|
-
diagnostics: diagnostics, secret_heuristics: secret_heuristics
|
|
111
|
+
redact: redact, diagnostics: diagnostics, secret_heuristics: secret_heuristics
|
|
109
112
|
)
|
|
110
113
|
Diagnostics.warn("init already called — returning the existing instance (providers are process-global; " \
|
|
111
114
|
"enabled and endpoint cannot change after the first init — only the redaction lists refresh)")
|
data/lib/foam/otel/redaction.rb
CHANGED
|
@@ -458,6 +458,127 @@ module Foam
|
|
|
458
458
|
true
|
|
459
459
|
end
|
|
460
460
|
|
|
461
|
+
# ---- THE OPT-IN PII DETECT TIER (fleet ruling 2026-07-28) --------------
|
|
462
|
+
# docs/decisions/redact-object-detect-design.md §3; fixture:
|
|
463
|
+
# contract/pii-detect.json. The THIRD value control at this seam, and the
|
|
464
|
+
# first that is FULLY OPT-IN: an entity from the frozen table
|
|
465
|
+
# (constants.rb PII_DETECT_ENTITIES) detects ONLY when its name is in the
|
|
466
|
+
# config's redact_detect list. It composes immediately AFTER the secret
|
|
467
|
+
# value layer and BEFORE customer key masking, masks ONLY the matched
|
|
468
|
+
# character span with the entity's TYPED placeholder ([EMAIL], [PHONE],
|
|
469
|
+
# [SSN], [CREDIT_CARD], [IP] — surrounding text preserved verbatim), and
|
|
470
|
+
# runs everywhere the secret layer runs — same exemption (metric
|
|
471
|
+
# datapoint attributes ride the value_scan: false path) and the same
|
|
472
|
+
# execution discipline: compile-once (per-Regexp timeout on Ruby >= 3.2),
|
|
473
|
+
# over-cap values are NEVER regex-scanned (the secret layer's anchor scan
|
|
474
|
+
# already made the oversize mask-whole/pass decision — detect adds
|
|
475
|
+
# nothing), a match flood masks the whole value, and ANY throw inside the
|
|
476
|
+
# detector masks the WHOLE value to [REDACTED] with a loud [foam]
|
|
477
|
+
# warning — never the raw value, never a crash (fixture "fail_closed").
|
|
478
|
+
|
|
479
|
+
# True only when the customer opted into at least one detect entity —
|
|
480
|
+
# the zero-cost gate for the byte-identical default path.
|
|
481
|
+
def detect_enabled?(config)
|
|
482
|
+
return false if config.nil?
|
|
483
|
+
return false unless config.respond_to?(:redact_detect)
|
|
484
|
+
|
|
485
|
+
list = config.redact_detect
|
|
486
|
+
!list.nil? && !list.empty?
|
|
487
|
+
rescue StandardError
|
|
488
|
+
false
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# THE detect-tier entrypoint. Same guard shape as scan_value_secrets:
|
|
492
|
+
# scan a scrubbed COPY of an invalid-encoding value; when nothing
|
|
493
|
+
# matches, the ORIGINAL object rides through (raw capture stands,
|
|
494
|
+
# identity-preserving).
|
|
495
|
+
def detect_pii(text, config)
|
|
496
|
+
return text unless text.is_a?(String) && !text.empty?
|
|
497
|
+
|
|
498
|
+
entities = config.redact_detect
|
|
499
|
+
work = text.valid_encoding? ? text : scrub_utf8(text)
|
|
500
|
+
work = text unless work.is_a?(String)
|
|
501
|
+
result = detect_pii_unguarded(work, entities)
|
|
502
|
+
result.equal?(work) ? text : result
|
|
503
|
+
rescue StandardError, SystemStackError
|
|
504
|
+
# Fail closed (fixture "fail_closed"): a detector fault ships the
|
|
505
|
+
# mask, NEVER the raw value.
|
|
506
|
+
warn_secret_scan_once(:pii_fault, "PII detection failed for a value — masked whole (fail-closed)")
|
|
507
|
+
REDACTED
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def detect_pii_unguarded(text, entities)
|
|
511
|
+
# Over-cap follows the secret layer's over-cap rule (fixture "caps"):
|
|
512
|
+
# NEVER regex-scanned. The secret layer's anchor scan already ran and
|
|
513
|
+
# decided mask-whole vs pass for oversized values; detect adds nothing.
|
|
514
|
+
return text if text.length > SECRET_VALUE_SCAN_CAP
|
|
515
|
+
|
|
516
|
+
spans = []
|
|
517
|
+
PII_DETECT_COMPILED.each do |name, entity|
|
|
518
|
+
next unless entities.include?(name)
|
|
519
|
+
|
|
520
|
+
group = entity[:group]
|
|
521
|
+
text.scan(entity[:re]) do
|
|
522
|
+
m = Regexp.last_match
|
|
523
|
+
cand = m[group]
|
|
524
|
+
next if cand.nil? || !detect_filter_ok?(entity[:filter], cand)
|
|
525
|
+
|
|
526
|
+
spans << [m.begin(group), m.end(group), entity[:placeholder]]
|
|
527
|
+
if spans.length > SECRET_MAX_MATCHES
|
|
528
|
+
# match flood ⇒ whole value masked (fail-closed, secret-layer parity)
|
|
529
|
+
warn_secret_scan_once(:pii_flood,
|
|
530
|
+
"PII detection exceeded the match-flood cap — masked whole (fail-closed)")
|
|
531
|
+
return REDACTED
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
return text if spans.empty?
|
|
536
|
+
|
|
537
|
+
apply_detect_spans(text, spans)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def detect_filter_ok?(filter, cand)
|
|
541
|
+
return true unless filter == :luhn
|
|
542
|
+
|
|
543
|
+
digits = cand.delete("^0-9")
|
|
544
|
+
digits.length.between?(13, 19) && luhn_valid?(digits)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# The Luhn checksum over a plain digit string. credit_card is masked
|
|
548
|
+
# ONLY when this passes (fixture "checksum" rule) — a card-shaped number
|
|
549
|
+
# failing Luhn (a tracking id, a serial) is never masked.
|
|
550
|
+
def luhn_valid?(digits)
|
|
551
|
+
sum = 0
|
|
552
|
+
digits.reverse.each_char.with_index do |ch, i|
|
|
553
|
+
d = ch.ord - 48
|
|
554
|
+
if i.odd?
|
|
555
|
+
d *= 2
|
|
556
|
+
d -= 9 if d > 9
|
|
557
|
+
end
|
|
558
|
+
sum += d
|
|
559
|
+
end
|
|
560
|
+
(sum % 10).zero?
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Replace each detected span with ITS entity's typed placeholder, left
|
|
564
|
+
# to right; overlaps resolved by earliest-start-longest-match (the
|
|
565
|
+
# secret layer's span-merge rule, fixture "span_masking"). Emitted
|
|
566
|
+
# placeholders are never rescanned — running detection twice is
|
|
567
|
+
# byte-identical (fixture "placeholder_idempotence").
|
|
568
|
+
def apply_detect_spans(text, spans)
|
|
569
|
+
spans.sort_by! { |s, e, _| [s, -e] }
|
|
570
|
+
out = +""
|
|
571
|
+
prev = 0
|
|
572
|
+
spans.each do |s, e, placeholder|
|
|
573
|
+
next if s < prev # overlapped by an earlier-start/longer match — dropped
|
|
574
|
+
|
|
575
|
+
out << text[prev...s] << placeholder
|
|
576
|
+
prev = e
|
|
577
|
+
end
|
|
578
|
+
out << text[prev..]
|
|
579
|
+
out
|
|
580
|
+
end
|
|
581
|
+
|
|
461
582
|
# Rate-limited (once per process per class) scanner warnings — loud
|
|
462
583
|
# (rule 15) but never a log flood an adversary controls.
|
|
463
584
|
def warn_secret_scan_once(key, message)
|
|
@@ -525,14 +646,16 @@ module Foam
|
|
|
525
646
|
end
|
|
526
647
|
|
|
527
648
|
# A redact_keys match: the value-pattern scan runs FIRST (design §V.0 —
|
|
528
|
-
# a shaped secret must never survive as a 4-char tail reveal)
|
|
529
|
-
#
|
|
530
|
-
#
|
|
649
|
+
# a shaped secret must never survive as a 4-char tail reveal), then the
|
|
650
|
+
# opt-in detect tier (2026-07-28 ruling: detect sits BETWEEN the secret
|
|
651
|
+
# layer and customer key masking); a V or detect hit is terminal for its
|
|
652
|
+
# span. Otherwise: tail-mask a scalar leaf, redact anything else in full.
|
|
531
653
|
def masked_value_for(value, config, value_scan)
|
|
532
654
|
return value.map { REDACTED } if value.is_a?(Array)
|
|
533
655
|
|
|
534
656
|
if value_scan && value.is_a?(String)
|
|
535
657
|
scanned = scan_value_secrets(value, heuristics_enabled?(config))
|
|
658
|
+
scanned = detect_pii(scanned, config) if detect_enabled?(config)
|
|
536
659
|
return scanned unless scanned == value
|
|
537
660
|
end
|
|
538
661
|
scalar_or_full(value)
|
|
@@ -566,12 +689,16 @@ module Foam
|
|
|
566
689
|
# query AND fragment, per-pair value scan), (2) the whole-string
|
|
567
690
|
# value-pattern scan for shaped secrets outside k=v pairs (a bearer
|
|
568
691
|
# token in an exception message, a PEM block, a bare JWT). [REDACTED]
|
|
569
|
-
# emitted by (1) never re-matches in (2).
|
|
692
|
+
# emitted by (1) never re-matches in (2). The OPT-IN detect tier (fleet
|
|
693
|
+
# ruling 2026-07-28) composes THIRD, after the secret layer — its typed
|
|
694
|
+
# placeholders never weaken (1)/(2), and with no opted-in entities it is
|
|
695
|
+
# a zero-cost no-op (the byte-identical default path).
|
|
570
696
|
def redact_leaf_string(text, config)
|
|
571
697
|
out = scan_value_secrets(redact_url_like(text, config), heuristics_enabled?(config))
|
|
572
|
-
|
|
573
|
-
#
|
|
574
|
-
#
|
|
698
|
+
out = detect_pii(out, config) if detect_enabled?(config)
|
|
699
|
+
# Raw capture stands byte-identical: when no pass changed anything,
|
|
700
|
+
# the ORIGINAL object rides through (content- and identity-preserving,
|
|
701
|
+
# pinned by the floor negative controls).
|
|
575
702
|
out == text ? text : out
|
|
576
703
|
end
|
|
577
704
|
|
|
@@ -928,6 +1055,14 @@ module Foam
|
|
|
928
1055
|
SECRET_H2_HEX_RE = build_secret_regexp(SECRET_HEURISTIC_H2[:hex_regex], false)
|
|
929
1056
|
SECRET_PLACEHOLDER_RES = SECRET_VALUE_PLACEHOLDERS.map { |p| build_secret_regexp(p, true) }.freeze
|
|
930
1057
|
|
|
1058
|
+
# The detect tier's compiled view of the frozen fixture table (fleet
|
|
1059
|
+
# ruling 2026-07-28) — same compile-once discipline (and the same
|
|
1060
|
+
# per-Regexp timeout belt) as the secret rules above: a pattern that
|
|
1061
|
+
# fails to compile fails the require LOUDLY, never a silent skip.
|
|
1062
|
+
PII_DETECT_COMPILED = PII_DETECT_ENTITIES.transform_values do |entity|
|
|
1063
|
+
entity.merge(re: build_secret_regexp(entity[:regex], false)).freeze
|
|
1064
|
+
end.freeze
|
|
1065
|
+
|
|
931
1066
|
# Every lowercase literal anchor across Tier 1 + the linear scans + H1 —
|
|
932
1067
|
# the single pre-filter deciding whether ANY regex runs (and the whole
|
|
933
1068
|
# decision for oversized values).
|
data/lib/foam/otel/version.rb
CHANGED
|
@@ -46,6 +46,21 @@ module Foam
|
|
|
46
46
|
# exporter — OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE is now INERT and
|
|
47
47
|
# loudly warned about (F-RB1, CWE-295). If a dashboard keyed off a raw
|
|
48
48
|
# token value (it should not have), it will now see [REDACTED].
|
|
49
|
-
|
|
49
|
+
# 1.5.0: `redact:` OBJECT + OPT-IN PII DETECT TIER (fleet ruling
|
|
50
|
+
# 2026-07-28, docs/decisions/redact-object-detect-design.md; MINOR —
|
|
51
|
+
# additive, opt-in, zero default-path behavior change). init() and all
|
|
52
|
+
# three door-2 ingest entries gain ONE additive option, redact: — a Hash
|
|
53
|
+
# grouping the two key lists under intent-named fields (secrets: ==
|
|
54
|
+
# redact_keys, pii: == redact_pii_keys; the legacy kwargs live on
|
|
55
|
+
# unchanged and UNION when both are set) plus a new opt-in detect: list
|
|
56
|
+
# of PII entity names (contract/pii-detect.json: email, phone, ssn,
|
|
57
|
+
# credit_card, ip) whose value shapes are masked in every exported
|
|
58
|
+
# string with typed placeholders ([EMAIL], [PHONE], [SSN],
|
|
59
|
+
# [CREDIT_CARD], [IP]) — span-only, Luhn-gated credit_card, idempotent
|
|
60
|
+
# over placeholders, same caps/fail-closed discipline and the same
|
|
61
|
+
# metric-label exemption as the secret layer. Unknown redact fields,
|
|
62
|
+
# non-Array values, and unknown detect entities raise at init (loud at
|
|
63
|
+
# boot). With redact: absent, behavior is byte-identical to 1.4.0.
|
|
64
|
+
VERSION = "1.5.0"
|
|
50
65
|
end
|
|
51
66
|
end
|