@redact-secret/adapter-otel-trace 0.1.1 → 0.1.2

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.
package/CHANGELOG.md CHANGED
@@ -25,6 +25,22 @@ version numbers start again here, at `0.1.0`, and do not follow the old name's.
25
25
 
26
26
  ## [Unreleased]
27
27
 
28
+ ## [0.1.2] - 2026-10-02
29
+
30
+ ### Added
31
+
32
+ - **One aggregate budget per span** (redact-secret/redact-secret-adapters#173), shared by the span name, every attribute, event and link and the status message. `operationLimits` overrides it (defaults as in `@redact-secret/adapter`); `redactAttributesWith` takes it for its one bag. A span ended re-entrantly inside the next processor has its own budget.
33
+
34
+ - **Verified core scan options** (redact-secret/redact-secret-adapters#175): `createRedactingSpanProcessor`, `RedactingSpanProcessorWith` and `redactAttributesWith` take `scanLimits`, `ruleset` and `placeholderFormatter` (and still `policy`), validated and snapshotted once. The live factory rejects an unsupported core, or a ruleset or limits the core refuses, with a fixed `CoreOptionsError`. Available from the declared core floor, verified at both endpoints by `test/scan-options-live.test.ts`. Omit them and nothing changes.
35
+
36
+ ### Changed
37
+
38
+ - **Key-aware detection** (redact-secret/redact-secret-adapters#172). A string attribute value on a span, event or link is now scanned with its attribute name as detection context, through the shared primitive in `@redact-secret/adapter`, so a context-dependent credential (`api_key`, `password`) is masked. The attribute name is never rewritten or output; string-array elements, the span name, event names and the status message have no direct key and are scanned as before. Cost: a string attribute is scanned twice unless its own scan already redacted or blocked it.
39
+
40
+ - **Behavior change with defaults:** a span that inspects more than the default budget now has every string not yet inspected replaced by `[REDACTED:LIMIT_EXCEEDED]` (counted as `limited`), and is still forwarded. Before, a span's only bound was `maxStringLength` per string.
41
+
42
+ - **Dependency range raised: `@redact-secret/adapter` `^0.1.3` -> `^0.1.7`** (redact-secret/redact-secret-adapters#172, #173, #175). This release needs the key-context primitive, the operation budget and the scan options that `@redact-secret/adapter` 0.1.7 introduces, which no earlier published version has; against `0.1.3` the package fails to import. Backed by the `published-combination` CI job (`scripts/check-published-combination.mjs`), which installs this package with the lowest published sibling its range admits (and this checkout's tarball for a sibling not yet published). No `@redact-secret/core` range change.
43
+
28
44
  ## [0.1.1] - 2026-10-01
29
45
  ### Changed
30
46
 
package/README.md CHANGED
@@ -1,7 +1,30 @@
1
1
  # @redact-secret/adapter-otel-trace
2
2
 
3
- A redacting OpenTelemetry JS `SpanProcessor` for **traces**, over the
4
- [Redact Secret](https://github.com/redact-secret/redact-secret) core.
3
+ [![npm version](https://img.shields.io/npm/v/@redact-secret/adapter-otel-trace)](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@redact-secret/adapter-otel-trace)](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
5
+ [![OpenTelemetry SDK peer range](https://img.shields.io/npm/dependency-version/@redact-secret/adapter-otel-trace/peer/@opentelemetry/sdk-trace-base)](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace?activeTab=dependencies)
6
+ [![Node.js](https://img.shields.io/node/v/@redact-secret/adapter-otel-trace)](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
7
+ [![types included](https://img.shields.io/npm/types/@redact-secret/adapter-otel-trace)](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
8
+ [![CI](https://github.com/redact-secret/redact-secret-adapters/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/redact-secret/redact-secret-adapters/actions/workflows/ci.yml)
9
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/redact-secret/redact-secret-adapters/badge)](https://scorecard.dev/viewer/?uri=github.com/redact-secret/redact-secret-adapters)
10
+ [![License: MIT](https://img.shields.io/npm/l/@redact-secret/adapter-otel-trace)](https://github.com/redact-secret/redact-secret-adapters/blob/main/LICENSE)
11
+
12
+ Keep secrets out of OpenTelemetry **traces**. Wrap the span processor you
13
+ already have, and span names, attributes, events and links are redacted before
14
+ they reach your exporter.
15
+
16
+ Built on the [Redact Secret](https://github.com/redact-secret/redact-secret)
17
+ core, which does the detection.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @redact-secret/core @redact-secret/adapter-otel-trace @opentelemetry/sdk-trace-base
23
+ ```
24
+
25
+ Needs Node.js 20, 22 or 24 and `@opentelemetry/sdk-trace-base ^2.0.0`. ESM only.
26
+
27
+ ## Quick start
5
28
 
6
29
  ```js
7
30
  import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
@@ -12,21 +35,7 @@ const provider = new NodeTracerProvider({
12
35
  });
13
36
  ```
14
37
 
15
- ## What it covers, and what it does not
16
-
17
- **Spans only.** This package plugs into the tracing pipeline and sees what a
18
- `SpanProcessor` sees. It does **not** protect OpenTelemetry **Logs**: a
19
- `LogRecord` emitted through `@opentelemetry/sdk-logs` (or a log bridge such as
20
- the pino or winston instrumentation) never passes through it and reaches its
21
- exporter as it was written. There is no OpenTelemetry Logs adapter yet;
22
- `@redact-secret/adapter-otel-logs` is a reserved name for one, not a package.
23
- Metrics are not covered either.
24
-
25
- This package was published as
26
- [`@redact-secret/adapter-otel`](https://www.npmjs.com/package/@redact-secret/adapter-otel) up to `0.1.2`. That name keeps working — its
27
- releases after `0.1.2` re-export this package — but new code should import
28
- from here; see
29
- [Migrating from `@redact-secret/adapter-otel`](#migrating-from-redact-secretadapter-otel).
38
+ A complete, runnable example:
30
39
 
31
40
  <!-- smoke-test:example -->
32
41
  ```js
@@ -57,95 +66,112 @@ await provider.shutdown();
57
66
  // ...{"name":"deploy <SECRET_1>",...,"attributes":[{"key":"llm.input_messages","value":{"stringValue":"deploy with token <SECRET_1>"}}],...
58
67
  ```
59
68
 
60
- The clean-install smoke test (`npm run smoke-test`) runs this block verbatim
61
- from a throwaway project outside the repository, against the real core, and
62
- inspects the exporter's bytes.
69
+ CI runs this block verbatim from a clean install outside the repository
70
+ (`npm run smoke-test`), against the real core, and inspects the exporter's
71
+ bytes.
63
72
 
64
- ## What is redacted
73
+ To check that your own provider and exporter are covered, run the
74
+ [placement recipe](https://github.com/redact-secret/redact-secret-adapters/tree/main/examples/placement-js):
75
+ it serializes a span with a synthetic token the way an exporter would and
76
+ includes a processor registered ahead of the redacting one as a negative control.
65
77
 
66
- In `onEnd`, before the span reaches the next processor, the processor redacts
67
- the span name, every string and string-array attribute (a `null` hole in an
68
- array is kept in place), every event's name and attributes, the status message,
69
- and every link's attributes. Attribute names are not
70
- allowlisted, so OpenInference (`llm.input_messages`, `input.value`, …) and GenAI
71
- semantic-convention attributes (`gen_ai.prompt`, …) are covered without
72
- hardcoding either convention.
78
+ ## What is covered
73
79
 
74
- ## The load-bearing assumption
80
+ | Covered | Not covered |
81
+ | --- | --- |
82
+ | The span name | OpenTelemetry **Logs** (`LogRecord`s from `@opentelemetry/sdk-logs` or a log bridge) |
83
+ | Every string and string-array attribute (a `null` hole in an array stays in place) | Metrics |
84
+ | Every event's name and attributes | Attribute **names**, which are never scanned on their own or rewritten (a name is only context for the string value under it). Do not put a secret in an attribute key |
85
+ | The status message | Spans the wrapped processor never receives: sampled out, or handled by a processor registered ahead of this one |
86
+ | Every link's attributes | |
75
87
 
76
- `ReadableSpan`'s fields are typed `readonly` but are plain writable objects at
77
- runtime, and this processor writes the masked values back in place. Every write
78
- is read back; if one does not take (for example, an earlier processor froze the
79
- attributes), the span is **dropped** — not exported, and nothing is thrown out
80
- of `span.end()` — and a one-time process warning
81
- (`REDACT_SECRET_SPAN_DROPPED`) names the field, never its value.
82
- `test/otel-host.test.ts` builds a real span, passes it through a real
83
- `BasicTracerProvider`, and asserts the exporter saw redacted fields. That
84
- assertion is not optional.
88
+ Attribute names are not allowlisted, so OpenInference (`llm.input_messages`,
89
+ `input.value`, …) and GenAI semantic-convention attributes (`gen_ai.prompt`,
90
+ …) are covered without hardcoding either convention.
85
91
 
86
- ## Exports
92
+ **This is a trace processor only.** A `LogRecord` never passes through it and
93
+ reaches its exporter as it was written. For logs use
94
+ [`@redact-secret/adapter-otel-logs`](https://github.com/redact-secret/redact-secret-adapters/tree/main/packages/adapter-otel-logs),
95
+ a separate package currently published as a beta (`0.1.0-beta.2`, dist-tag `beta`).
87
96
 
88
- | Export | Purpose |
89
- | --- | --- |
90
- | `createRedactingSpanProcessor(next, options?)` | Live: awaits the core's `initialize()`, wraps `next` |
91
- | `RedactingSpanProcessorWith` | `new (next, scanAndRedact, options?)` — injected scanner |
92
- | `redactAttributesWith(scanAndRedact, attributes, options?)` | Mutates one attribute bag in place; throws a `TypeError` (naming no value) if it cannot |
97
+ When a value cannot be scanned, a fixed marker replaces it. See
98
+ [`@redact-secret/adapter`](https://github.com/redact-secret/redact-secret-adapters/tree/main/packages/adapter#fail-closed-markers)
99
+ for the markers.
93
100
 
94
- `options` is `{ policy, maxStringLength }` — `MaskLeafOptions`, re-exported
95
- here — plus `onOutcome` and, on the live factory, `pii` — every option
96
- `adapter-otel` `0.1.2` had. The older `RedactAttributesOptions` alias is deprecated. See
97
- [`@redact-secret/adapter`](../adapter#fail-closed-markers) for the markers.
101
+ ### A span that cannot be redacted is dropped
98
102
 
99
- **Attribute names are not scanned.** The processor masks attribute *values*
100
- (and the span name, event names, and status message). Every attribute key,
101
- on the span, its events, and its links, reaches the exporter unchanged, so an
102
- attribute *named* after a secret keeps that name. Do not put a secret in an
103
- attribute key.
103
+ (Other markers and limits, and what to do about each:
104
+ [troubleshooting](https://github.com/redact-secret/redact-secret-adapters/blob/main/docs/troubleshooting.md#logs-and-spans-markers).)
104
105
 
105
- ## PII detection is opt-in
106
+ `ReadableSpan`'s fields are typed `readonly` but are plain writable objects at
107
+ runtime, and this processor writes the masked values back in place. Every
108
+ write is read back. If one does not take (for example, an earlier processor
109
+ froze the attributes), the span is **dropped**: not exported, and nothing is
110
+ thrown out of `span.end()`. A one-time process warning
111
+ (`REDACT_SECRET_SPAN_DROPPED`) names the field, never its value.
106
112
 
107
- The core detects credentials out of the box; PII detection is a
108
- separate activation, and it is process-wide and one-shot — the first selection
109
- wins, and a later *different* one fails with `PII_ACTIVATION_CONFLICT`.
113
+ `test/otel-host.test.ts` builds a real span, passes it through a real
114
+ `BasicTracerProvider`, and asserts the exporter saw redacted fields. That
115
+ assertion is not optional.
110
116
 
111
- Either order works. Activate it yourself before building the provider:
117
+ ## Options
112
118
 
113
119
  ```js
114
- await initialize({ pii: ["pii:global"] });
115
- const processor = await createRedactingSpanProcessor(next); // accepted, not fought over
120
+ await createRedactingSpanProcessor(next, { pii, onOutcome, policy, maxStringLength, operationLimits, scanLimits, ruleset, placeholderFormatter });
116
121
  ```
117
122
 
118
- or let the factory do it, which is the order to prefer when this adapter is the
119
- first thing in the process to touch the core:
123
+ | Option | What it does |
124
+ | --- | --- |
125
+ | `pii` | Turn on PII detection, e.g. `["pii:global"]`. See below |
126
+ | `onOutcome` | A callback with counts per span, for your metrics. See below |
127
+ | `policy` | The core's policy, passed through unchanged. It replaces the core's built-in policy for every finding, a `ruleset` detector's included |
128
+ | `scanLimits` | The core's whole-input limits, `{ maxInputBytes, maxFindings }`, for every scan. See [Core scan options](https://github.com/redact-secret/redact-secret-adapters/tree/main/packages/adapter#core-scan-options) |
129
+ | `ruleset` | A declarative detector ruleset (text or bytes) |
130
+ | `placeholderFormatter` | The core's placeholder formatter |
131
+ | `maxStringLength` | Strings longer than this become `[REDACTED:LIMIT_EXCEEDED]` unscanned |
132
+ | `operationLimits` | Override the aggregate budget of **one span**. See below |
133
+
134
+ ### One budget per span
135
+
136
+ The span name, every attribute, every event and link, and the status message
137
+ share **one** aggregate budget per span, so a span with many events and links
138
+ cannot multiply the scanning even when each string is within `maxStringLength`.
139
+ Past a bound every string not yet inspected becomes
140
+ `[REDACTED:LIMIT_EXCEEDED]` unscanned and the span is still forwarded, never with
141
+ text the budget did not allow to be inspected; nothing in `onOutcome` carries
142
+ input. A key-context scan counts as a scan, and attribute names count as keys.
143
+ A span ended re-entrantly inside the next processor has its own budget. Units,
144
+ defaults and the caveat that this is a work counter and not a timeout are in
145
+ [`@redact-secret/adapter`](https://github.com/redact-secret/redact-secret-adapters/tree/main/packages/adapter#aggregate-operation-budget).
146
+
147
+ ### PII detection
148
+
149
+ The core detects credentials out of the box. PII detection is a separate
150
+ activation:
120
151
 
121
152
  ```js
122
153
  const processor = await createRedactingSpanProcessor(next, { pii: ["pii:global"] });
123
154
  ```
124
155
 
125
- When you pass `pii`, the factory reads the core's `piiActivation()` afterwards
126
- and **rejects** if the active selection is not the one you asked for, rather
127
- than returning a processor that scans with PII silently off. The rejection
128
- carries a fixed `code` — `PII_ACTIVATION_NOT_ACTIVE`, or
129
- `PII_ACTIVATION_UNSUPPORTED` against a core too old to report an activation —
130
- and never echoes a selector, the input, or the core's own message. Omitting
131
- `pii` needs no newer core: the declared `@redact-secret/core` range is
132
- unchanged, and every other initialization failure still rejects exactly as it
133
- did.
134
-
135
- **Activation is not masking.** Under the core's default policy, PII types are
136
- confidence-gated rather than always redacted: a `High`-confidence finding
137
- redacts, while `Medium` and `Low` resolve to `warn` — and a `warn` finding
138
- leaves the text alone. Enabling PII therefore still lets lower-confidence PII
139
- reach the exporter as plaintext. Pass your own `policy` mapping those findings
140
- to `redact` if you need them masked; this package decides nothing about policy.
141
- The counters below make it visible: a span whose `values.findings` is non-zero
142
- while `values.redacted` stays at zero is exactly this case.
143
-
144
- ## Counting what happened
145
-
146
- `onOutcome` reports one summary per **span**. It is
147
- observational: increment your own counters from it. This package creates no
148
- exporter or network client for you.
156
+ It is process-wide and one-shot. If the selection you asked for is not the
157
+ one active, the factory **rejects** with a fixed `code`
158
+ (`PII_ACTIVATION_NOT_ACTIVE` or `PII_ACTIVATION_UNSUPPORTED`) rather than
159
+ returning a processor that scans with PII silently off.
160
+
161
+ **Activation is not masking.** Under the core's default policy only
162
+ `High`-confidence PII is redacted; `Medium` and `Low` resolve to `warn`, which
163
+ leaves the text alone. Pass your own `policy` if you need those masked. A span
164
+ whose `values.findings` is non-zero while `values.redacted` stays at zero is
165
+ exactly this case.
166
+
167
+ Full rules:
168
+ [PII guide](https://github.com/redact-secret/redact-secret-adapters/blob/main/docs/pii.md).
169
+
170
+ ### Counting what happened
171
+
172
+ `onOutcome` reports one summary per **span**. It is observational: increment
173
+ your own counters from it. This package creates no exporter or network client
174
+ for you.
149
175
 
150
176
  ```js
151
177
  const processor = await createRedactingSpanProcessor(new BatchSpanProcessor(exporter), {
@@ -162,37 +188,47 @@ const processor = await createRedactingSpanProcessor(new BatchSpanProcessor(expo
162
188
  dropped: false }
163
189
  ```
164
190
 
165
- The counts are defined in
166
- [`@redact-secret/adapter`](../adapter#outcome-counters) — `findings` is not a
167
- count of distinct credentials, and `redacted` is lower than `findings` whenever
168
- a finding leaves text alone. Every string attribute, array element, event name
169
- and status message is its own counted leaf; attribute *names* are not scanned
170
- and not counted.
191
+ - Every string attribute, array element, event name and status message is its
192
+ own counted leaf. Attribute *names* are not scanned and not counted.
193
+ - `findings` is not a count of distinct credentials, and `redacted` is lower
194
+ than `findings` whenever a finding leaves text alone. The counts are defined
195
+ in
196
+ [`@redact-secret/adapter`](https://github.com/redact-secret/redact-secret-adapters/tree/main/packages/adapter#outcome-counters).
197
+ - `dropped` is **this processor's** decision: it did not hand the span to the
198
+ next processor because a masked value would not write back. It does not mean
199
+ the span was sampled out, and `dropped: false` does **not** mean the span was
200
+ exported. Whether the next processor kept it and whether an exporter
201
+ succeeded are things this adapter never learns.
202
+ - The observer runs once the span has been forwarded or dropped, so it cannot
203
+ change what is exported. Anything it throws is swallowed and never read.
204
+ - It is re-entrancy-guarded: an observer that ends another span does not
205
+ recurse.
171
206
 
172
- `dropped` is **this processor's** decision: it did not hand the span to the
173
- next processor because a masked value would not write back (see the
174
- load-bearing assumption above). It does not mean the span was sampled out, and
175
- `dropped: false` does **not** mean the span was exported — whether the next
176
- processor kept it and whether an exporter succeeded are things this adapter
177
- never learns and does not report.
207
+ ## Exports
178
208
 
179
- - The observer runs once the span has been forwarded or dropped, so it cannot
180
- change what is exported, and anything it throws is swallowed and never read.
181
- - It is re-entrancy- and thread-guarded: an observer that ends another span
182
- does not recurse, and one thread's report never suppresses another's.
209
+ | Export | Purpose |
210
+ | --- | --- |
211
+ | `createRedactingSpanProcessor(next, options?)` | Live: awaits the core's `initialize()`, wraps `next` |
212
+ | `RedactingSpanProcessorWith` | `new (next, scanAndRedact, options?)`, with an injected scanner |
213
+ | `redactAttributesWith(scanAndRedact, attributes, options?)` | Mutates one attribute bag in place; throws a `TypeError` (naming no value) if it cannot |
183
214
 
184
- ## Supported SDK versions
215
+ `options` is `{ policy, maxStringLength }` (`MaskLeafOptions`, re-exported
216
+ here) plus `onOutcome` and, on the live factory, `pii`. The older
217
+ `RedactAttributesOptions` alias is deprecated.
218
+
219
+ ## Supported versions
185
220
 
186
221
  `@opentelemetry/sdk-trace-base ^2.0.0` and `@redact-secret/core`
187
222
  `^0.1.0-beta.6`. CI runs the real-host tests at both ends of each range. The
188
- SDK is imported as types only — it never enters this package's runtime graph.
223
+ SDK is imported as types only. It never enters this package's runtime graph.
189
224
 
190
225
  ## Migrating from `@redact-secret/adapter-otel`
191
226
 
192
- `0.1.0` of this package is the code `@redact-secret/adapter-otel` `0.1.2`
193
- shipped: the same exports, options, outcome shape, peer ranges and fail-closed
194
- markers. Only the name changes, so migrating is a dependency swap and an import
195
- specifier:
227
+ This package was published as
228
+ [`@redact-secret/adapter-otel`](https://www.npmjs.com/package/@redact-secret/adapter-otel)
229
+ up to `0.1.2`. `0.1.0` of this package is that same code: the same exports,
230
+ options, outcome shape, peer ranges and fail-closed markers. Only the name
231
+ changes, so migrating is a dependency swap and an import specifier:
196
232
 
197
233
  ```sh
198
234
  npm uninstall @redact-secret/adapter-otel
@@ -204,15 +240,20 @@ npm install @redact-secret/adapter-otel-trace
204
240
  +import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel-trace";
205
241
  ```
206
242
 
207
- Nothing breaks if you do not migrate yet. `@redact-secret/adapter-otel`
208
- `0.1.2` is this code under the old name, and its later releases re-export this
209
- package, so both names hand out the same functions and the same
210
- `RedactingSpanProcessorWith` class and mixing them in one process is safe.
211
- Those later releases mark every export `@deprecated`, which editors show as a
212
- strikethrough.
243
+ Nothing breaks if you do not migrate yet. Later releases of
244
+ `@redact-secret/adapter-otel` re-export this package, so both names hand out
245
+ the same functions and the same `RedactingSpanProcessorWith` class, and mixing
246
+ them in one process is safe. Those releases mark every export `@deprecated`,
247
+ which editors show as a strikethrough.
213
248
 
214
249
  Neither name protects OpenTelemetry Logs, before or after migrating.
215
250
 
251
+ ## Contributing
252
+
253
+ Issues and pull requests are welcome:
254
+ [CONTRIBUTING.md](https://github.com/redact-secret/redact-secret-adapters/blob/main/CONTRIBUTING.md).
255
+ Changes are listed in this package's `CHANGELOG.md`.
256
+
216
257
  ## License
217
258
 
218
259
  MIT
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * PII off. See `@redact-secret/adapter`'s `activateCore` for the whole rule,
28
28
  * including why activation is not the same as masking every PII value.
29
29
  */
30
- import { activateCore } from "@redact-secret/adapter";
30
+ import { activateCore, resolveScanConfig, verifyScanOptions } from "@redact-secret/adapter";
31
31
  import { RedactingSpanProcessorWith } from "./span-processor.js";
32
32
  export { RedactingSpanProcessorWith, redactAttributesWith, } from "./span-processor.js";
33
33
  /**
@@ -44,7 +44,11 @@ export async function createRedactingSpanProcessor(next, options = {}) {
44
44
  // `options` is forwarded as the same object so every other inherited key
45
45
  // does too. `pii` rides along unread, as any unknown key would.
46
46
  const activation = options.pii === undefined ? {} : { pii: options.pii };
47
+ // Validated before the core is touched; then the installed core must honor
48
+ // any requested scan option or this rejects with a fixed `CoreOptionsError`.
49
+ const config = resolveScanConfig(options);
47
50
  const loaded = await import("@redact-secret/core");
48
51
  await activateCore(loaded, activation);
52
+ verifyScanOptions(loaded, config);
49
53
  return new RedactingSpanProcessorWith(next, loaded.scanAndRedact, options);
50
54
  }
@@ -26,7 +26,7 @@
26
26
  * plain object.
27
27
  */
28
28
  import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
29
- import type { MaskLeafOptions, ScanAndRedact, ValueCounts } from "@redact-secret/adapter";
29
+ import type { MaskLeafOptions, OperationLimits, ScanAndRedact, ValueCounts } from "@redact-secret/adapter";
30
30
  /**
31
31
  * @deprecated Use `MaskLeafOptions` (`{ policy, maxStringLength }`), which
32
32
  * this package re-exports; this alias adds nothing and will be removed in a
@@ -52,7 +52,16 @@ export interface OtelSpanOutcome {
52
52
  */
53
53
  readonly dropped: boolean;
54
54
  }
55
- export interface RedactingSpanProcessorOptions extends MaskLeafOptions {
55
+ export interface RedactingSpanProcessorOptions extends Omit<MaskLeafOptions, "budget"> {
56
+ /**
57
+ * Overrides for the aggregate budget of **one span** (see
58
+ * `@redact-secret/adapter`'s `budget.ts`), shared by the span name, every
59
+ * attribute, event and link and the status message. Past it, every string
60
+ * not yet inspected becomes `[REDACTED:LIMIT_EXCEEDED]` and the core is not
61
+ * called; the span is still forwarded, never with text the budget did not
62
+ * allow to be inspected. It is a work counter, not a wall-clock timeout.
63
+ */
64
+ readonly operationLimits?: Partial<OperationLimits> | undefined;
56
65
  /**
57
66
  * Observational: called once per span, synchronously at the end of `onEnd`,
58
67
  * after the span has either been forwarded or dropped. Increment your own
@@ -25,37 +25,51 @@
25
25
  * wrapping one needs no dependency, and this module is testable with a
26
26
  * plain object.
27
27
  */
28
- import { countLeaf, createOutcomeCounter, ERROR_MARKER, maskLeafOutcomeWith, notify, toValueCounts, } from "@redact-secret/adapter";
28
+ import { countLeaf, createOperationBudget, createOutcomeCounter, ERROR_MARKER, LIMIT_MARKER, maskLeafOutcomeWith, notify, resolveScanConfig, toValueCounts, } from "@redact-secret/adapter";
29
29
  /** A span field that did not take a masked write. The message names the field, never its value. */
30
30
  class UnredactableFieldError extends Error {
31
31
  }
32
32
  /**
33
- * `counter` is read on every call, not captured, so one masker serves every
34
- * span and the processor can swap in a fresh per-span counter.
33
+ * `state` is read on every call, not captured, so one masker serves every
34
+ * span and the processor can swap in a fresh per-span counter and budget.
35
35
  */
36
- function maskerFor(scanAndRedact, options, counter) {
37
- return (text) => {
36
+ function maskerFor(scanAndRedact, options, state) {
37
+ return (text, key) => {
38
+ const { counter, budget } = state();
38
39
  try {
39
- const leaf = maskLeafOutcomeWith(scanAndRedact, text, options);
40
+ // One leaf of the span's budget; the scans it makes are charged inside.
41
+ if (!budget.chargeLeaf()) {
42
+ if (counter !== undefined)
43
+ counter.limited += 1;
44
+ return LIMIT_MARKER;
45
+ }
46
+ const leaf = maskLeafOutcomeWith(scanAndRedact, text, { ...options, key, budget });
40
47
  if (counter !== undefined)
41
- countLeaf(counter(), leaf);
48
+ countLeaf(counter, leaf);
42
49
  return leaf.text;
43
50
  }
44
51
  catch {
45
52
  if (counter !== undefined)
46
- counter().failed += 1;
53
+ counter.failed += 1;
47
54
  return ERROR_MARKER;
48
55
  }
49
56
  };
50
57
  }
51
58
  /** The masked value, or `value` itself when nothing in it changed. */
52
- function maskAttributeValue(mask, value) {
59
+ function maskAttributeValue(mask, budget, value, key) {
60
+ // Every value, and every element of an array value, is a node of the span's budget.
61
+ budget.chargeNode();
53
62
  if (typeof value === "string")
54
- return mask(value);
63
+ return mask(value, key);
55
64
  if (Array.isArray(value)) {
56
65
  // OpenTelemetry allows null/undefined holes in a homogeneous array, so
57
66
  // every string element is masked and every other element kept in place.
58
- const masked = value.map((item) => (typeof item === "string" ? mask(item) : item));
67
+ // An element is not directly under the attribute's name, so it is masked
68
+ // without key context, as an array element is everywhere else.
69
+ const masked = value.map((item) => {
70
+ budget.chargeNode();
71
+ return typeof item === "string" ? mask(item) : item;
72
+ });
59
73
  return masked.some((item, index) => item !== value[index]) ? masked : value;
60
74
  }
61
75
  // Numbers and booleans cannot carry a secret as free text.
@@ -75,11 +89,14 @@ function writeBack(target, key, value, field) {
75
89
  if (record[key] !== value)
76
90
  throw new UnredactableFieldError(`${field} did not take the masked write`);
77
91
  }
78
- function redactBag(mask, bag, field) {
92
+ function redactBag(mask, budget, bag, field) {
79
93
  if (bag == null)
80
94
  return;
81
95
  for (const key of Object.keys(bag)) {
82
- writeBack(bag, key, maskAttributeValue(mask, bag[key]), field);
96
+ // Charged as a key occurrence. Failing it exhausts the budget, and every
97
+ // string from here on is replaced by a marker rather than inspected.
98
+ budget.chargeKey();
99
+ writeBack(bag, key, maskAttributeValue(mask, budget, bag[key], key), field);
83
100
  }
84
101
  }
85
102
  /**
@@ -87,31 +104,39 @@ function redactBag(mask, bag, field) {
87
104
  * `TypeError` naming no value if a masked value cannot be written back.
88
105
  */
89
106
  export function redactAttributesWith(scanAndRedact, attributes, options = {}) {
107
+ const { operationLimits, ...leafOptions } = options;
108
+ const budget = createOperationBudget(operationLimits);
109
+ const resolved = { ...leafOptions, scanConfig: leafOptions.scanConfig ?? resolveScanConfig(leafOptions) };
90
110
  try {
91
- redactBag(maskerFor(scanAndRedact, options), attributes, "attributes");
111
+ redactBag(maskerFor(scanAndRedact, resolved, () => ({ counter: undefined, budget })), budget, attributes, "attributes");
92
112
  }
93
113
  catch {
94
114
  throw new TypeError("redactAttributesWith: a masked attribute could not be written back");
95
115
  }
96
116
  }
97
- function redactSpan(mask, span) {
98
- if (typeof span.name === "string")
117
+ function redactSpan(mask, budget, span) {
118
+ if (typeof span.name === "string") {
119
+ budget.chargeNode();
99
120
  writeBack(span, "name", mask(span.name), "span.name");
100
- redactBag(mask, span.attributes, "span.attributes");
121
+ }
122
+ redactBag(mask, budget, span.attributes, "span.attributes");
101
123
  const status = span.status;
102
124
  if (typeof status?.message === "string") {
125
+ budget.chargeNode();
103
126
  const message = mask(status.message);
104
127
  // Replaced, not mutated: the status object may be the caller's own.
105
128
  if (message !== status.message)
106
129
  writeBack(span, "status", { ...status, message }, "span.status");
107
130
  }
108
131
  for (const [index, event] of (span.events ?? []).entries()) {
132
+ budget.chargeNode();
109
133
  if (typeof event.name === "string")
110
134
  writeBack(event, "name", mask(event.name), `span.events[${index}].name`);
111
- redactBag(mask, event.attributes, `span.events[${index}].attributes`);
135
+ redactBag(mask, budget, event.attributes, `span.events[${index}].attributes`);
112
136
  }
113
137
  for (const [index, link] of (span.links ?? []).entries()) {
114
- redactBag(mask, link.attributes, `span.links[${index}].attributes`);
138
+ budget.chargeNode();
139
+ redactBag(mask, budget, link.attributes, `span.links[${index}].attributes`);
115
140
  }
116
141
  }
117
142
  /**
@@ -125,6 +150,10 @@ export class RedactingSpanProcessorWith {
125
150
  #mask;
126
151
  #onOutcome;
127
152
  #counter = createOutcomeCounter();
153
+ // The span being masked right now: one budget per `onEnd`, swapped like the
154
+ // counter so a downstream processor that ends a span re-entrantly has its own.
155
+ #budget;
156
+ #limits;
128
157
  #reporting = false;
129
158
  #warned = false;
130
159
  constructor(next, scanAndRedact, options = {}) {
@@ -134,13 +163,22 @@ export class RedactingSpanProcessorWith {
134
163
  if (typeof scanAndRedact !== "function") {
135
164
  throw new TypeError("RedactingSpanProcessorWith: scanAndRedact must be a function");
136
165
  }
137
- const { onOutcome, ...maskOptions } = options;
166
+ const { onOutcome, operationLimits, ...rawOptions } = options;
167
+ // Validated and snapshotted once, here: a malformed scan option is a
168
+ // programming error at construction, and every leaf is scanned with the
169
+ // one snapshot.
170
+ const maskOptions = { ...rawOptions, scanConfig: rawOptions.scanConfig ?? resolveScanConfig(rawOptions) };
138
171
  if (onOutcome !== undefined && typeof onOutcome !== "function") {
139
172
  throw new TypeError("RedactingSpanProcessorWith: onOutcome must be a function");
140
173
  }
141
174
  this.#next = next;
142
175
  this.#onOutcome = onOutcome;
143
- this.#mask = maskerFor(scanAndRedact, maskOptions, onOutcome === undefined ? undefined : () => this.#counter);
176
+ this.#limits = operationLimits;
177
+ this.#budget = createOperationBudget(operationLimits);
178
+ this.#mask = maskerFor(scanAndRedact, maskOptions, () => ({
179
+ counter: onOutcome === undefined ? undefined : this.#counter,
180
+ budget: this.#budget,
181
+ }));
144
182
  }
145
183
  onStart(...args) {
146
184
  this.#next.onStart?.(...args);
@@ -162,13 +200,15 @@ export class RedactingSpanProcessorWith {
162
200
  // `SimpleSpanProcessor` over an instrumented exporter, or any processor
163
201
  // that emits a span of its own), which re-enters this method.
164
202
  const outer = this.#counter;
203
+ const outerBudget = this.#budget;
165
204
  if (counting)
166
205
  this.#counter = createOutcomeCounter();
206
+ this.#budget = createOperationBudget(this.#limits);
167
207
  let dropped = false;
168
208
  let counts;
169
209
  try {
170
210
  try {
171
- redactSpan(this.#mask, span);
211
+ redactSpan(this.#mask, this.#budget, span);
172
212
  }
173
213
  catch (error) {
174
214
  this.#warnDropped(error instanceof UnredactableFieldError ? error.message : "unexpected span shape");
@@ -182,6 +222,7 @@ export class RedactingSpanProcessorWith {
182
222
  }
183
223
  finally {
184
224
  this.#counter = outer;
225
+ this.#budget = outerBudget;
185
226
  }
186
227
  // Reported whether the span was forwarded or dropped, and after the next
187
228
  // processor has had it, so an observer cannot affect what is exported.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redact-secret/adapter-otel-trace",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A redacting OpenTelemetry JS SpanProcessor (traces only) over the Redact Secret core.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "typecheck": "tsc -p tsconfig.json --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@redact-secret/adapter": "^0.1.3"
38
+ "@redact-secret/adapter": "^0.1.7"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@opentelemetry/sdk-trace-base": "^2.0.0",