@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 +16 -0
- package/README.md +155 -114
- package/dist/index.js +5 -1
- package/dist/span-processor.d.ts +11 -2
- package/dist/span-processor.js +63 -22
- package/package.json +2 -2
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
|
-
|
|
4
|
-
[
|
|
3
|
+
[](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
|
|
4
|
+
[](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
|
|
5
|
+
[](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace?activeTab=dependencies)
|
|
6
|
+
[](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
|
|
7
|
+
[](https://www.npmjs.com/package/@redact-secret/adapter-otel-trace)
|
|
8
|
+
[](https://github.com/redact-secret/redact-secret-adapters/actions/workflows/ci.yml)
|
|
9
|
+
[](https://scorecard.dev/viewer/?uri=github.com/redact-secret/redact-secret-adapters)
|
|
10
|
+
[](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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
(and
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
117
|
+
## Options
|
|
112
118
|
|
|
113
119
|
```js
|
|
114
|
-
await
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
count of distinct credentials, and `redacted` is lower
|
|
168
|
-
a finding leaves text alone.
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
208
|
-
`
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
}
|
package/dist/span-processor.d.ts
CHANGED
|
@@ -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
|
package/dist/span-processor.js
CHANGED
|
@@ -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
|
-
* `
|
|
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,
|
|
37
|
-
return (text) => {
|
|
36
|
+
function maskerFor(scanAndRedact, options, state) {
|
|
37
|
+
return (text, key) => {
|
|
38
|
+
const { counter, budget } = state();
|
|
38
39
|
try {
|
|
39
|
-
|
|
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
|
|
48
|
+
countLeaf(counter, leaf);
|
|
42
49
|
return leaf.text;
|
|
43
50
|
}
|
|
44
51
|
catch {
|
|
45
52
|
if (counter !== undefined)
|
|
46
|
-
counter
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
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.#
|
|
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.
|
|
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.
|
|
38
|
+
"@redact-secret/adapter": "^0.1.7"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|