@redact-secret/adapter-otel-trace 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@redact-secret/adapter-otel-trace` are documented in
4
+ this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ Every package in this repository carries its own SemVer and is released
7
+ independently — see [ARCHITECTURE.md § Versioning](../../ARCHITECTURE.md#versioning).
8
+ A change to the range this package declares against
9
+ `@opentelemetry/sdk-trace-base` or against `@redact-secret/core` is always its
10
+ own entry, naming the test that backs the new range, never folded into a
11
+ generic "bump dependency" line.
12
+
13
+ To release: in a PR into `develop`, move this section's `Unreleased`
14
+ entries under a new `## [x.y.z] - YYYY-MM-DD` heading matching the version
15
+ bumped in `package.json`. The next release train publishes and tags every
16
+ package whose declared version isn't on its registry yet — see
17
+ [RELEASING.md](../../RELEASING.md). This file ships inside the published
18
+ tarball (`files` in `package.json`), so a consumer can read it from
19
+ `node_modules` without leaving their editor.
20
+
21
+ This package's code shipped as `@redact-secret/adapter-otel` `0.1.0` to
22
+ `0.1.2`; that history is in
23
+ [`packages/adapter-otel/CHANGELOG.md`](../adapter-otel/CHANGELOG.md). Its
24
+ version numbers start again here, at `0.1.0`, and do not follow the old name's.
25
+
26
+ ## [Unreleased]
27
+
28
+ ## [0.1.0] - 2026-09-30
29
+ ### Added
30
+
31
+ - The package, under a name that says what it covers: OpenTelemetry JS
32
+ **traces** (redact-secret/redact-secret-adapters#49). It is the code
33
+ `@redact-secret/adapter-otel` `0.1.2` shipped, unchanged: the same
34
+ `createRedactingSpanProcessor`, `RedactingSpanProcessorWith`,
35
+ `redactAttributesWith`, options (`policy`, `maxStringLength`, `onOutcome`,
36
+ and `pii` on the live factory) and outcome shape, and the same
37
+ `REDACT_SECRET_SPAN_DROPPED` warning, whose message still begins
38
+ `@redact-secret/adapter-otel:` so a filter written against it keeps
39
+ matching. It protects spans only — the span name, attributes, events, status
40
+ message and link attributes. OpenTelemetry Logs (`LogRecord`s) pass through
41
+ no code in this package.
42
+ - Declares `@opentelemetry/sdk-trace-base` `^2.0.0` and `@redact-secret/core`
43
+ `^0.1.0-beta.6`, the ranges `adapter-otel` `0.1.2` declared, backed by the
44
+ same real-host tests (`test/otel-host.test.ts`, `test/otel-lifecycle.test.ts`,
45
+ `test/outcome.test.ts`, `test/vault-token.test.ts`), which CI runs at both
46
+ ends of each range. `test/exporter-bytes.test.ts` adds the final OTLP JSON
47
+ bytes an exporter would send, for the injected and the live factory, through
48
+ both `SimpleSpanProcessor` and `BatchSpanProcessor`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omiologic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @redact-secret/adapter-otel-trace
2
+
3
+ A redacting OpenTelemetry JS `SpanProcessor` for **traces**, over the
4
+ [Redact Secret](https://github.com/redact-secret/redact-secret) core.
5
+
6
+ ```js
7
+ import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
8
+ import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel-trace";
9
+
10
+ const provider = new NodeTracerProvider({
11
+ spanProcessors: [await createRedactingSpanProcessor(new BatchSpanProcessor(exporter))],
12
+ });
13
+ ```
14
+
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).
30
+
31
+ <!-- smoke-test:example -->
32
+ ```js
33
+ import { BasicTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
34
+ import { JsonTraceSerializer } from "@opentelemetry/otlp-transformer";
35
+ import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel-trace";
36
+
37
+ // Synthetic, revoked-shaped values only — never a real credential.
38
+ const token = "ghp_SYNTHETICREVOKED00000000000000000000";
39
+
40
+ // Writes the OTLP/JSON request body an OTLP http/json exporter would send.
41
+ const exporter = {
42
+ export(spans, done) {
43
+ process.stdout.write(`${new TextDecoder().decode(JsonTraceSerializer.serializeRequest(spans))}\n`);
44
+ done({ code: 0 });
45
+ },
46
+ shutdown: async () => {},
47
+ };
48
+
49
+ const provider = new BasicTracerProvider({
50
+ spanProcessors: [await createRedactingSpanProcessor(new SimpleSpanProcessor(exporter))],
51
+ });
52
+ const span = provider.getTracer("checkout").startSpan(`deploy ${token}`);
53
+ span.setAttribute("llm.input_messages", `deploy with token ${token}`);
54
+ span.addEvent("tool_call", { "tool.args": `Bearer ${token}` });
55
+ span.end();
56
+ await provider.shutdown();
57
+ // ...{"name":"deploy <SECRET_1>",...,"attributes":[{"key":"llm.input_messages","value":{"stringValue":"deploy with token <SECRET_1>"}}],...
58
+ ```
59
+
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.
63
+
64
+ ## What is redacted
65
+
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.
73
+
74
+ ## The load-bearing assumption
75
+
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.
85
+
86
+ ## Exports
87
+
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 |
93
+
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.
98
+
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.
104
+
105
+ ## PII detection is opt-in
106
+
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`.
110
+
111
+ Either order works. Activate it yourself before building the provider:
112
+
113
+ ```js
114
+ await initialize({ pii: ["pii:global"] });
115
+ const processor = await createRedactingSpanProcessor(next); // accepted, not fought over
116
+ ```
117
+
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:
120
+
121
+ ```js
122
+ const processor = await createRedactingSpanProcessor(next, { pii: ["pii:global"] });
123
+ ```
124
+
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.
149
+
150
+ ```js
151
+ const processor = await createRedactingSpanProcessor(new BatchSpanProcessor(exporter), {
152
+ onOutcome: ({ values, dropped }) => {
153
+ metrics.increment("span.redacted_values", values.redacted);
154
+ if (dropped) metrics.increment("span.dropped_unredactable");
155
+ },
156
+ });
157
+ ```
158
+
159
+ ```text
160
+ { host: "otel", unit: "span",
161
+ values: { scanned, findings, redacted, blocked, limited, failed },
162
+ dropped: false }
163
+ ```
164
+
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.
171
+
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.
178
+
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.
183
+
184
+ ## Supported SDK versions
185
+
186
+ `@opentelemetry/sdk-trace-base ^2.0.0` and `@redact-secret/core`
187
+ `^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.
189
+
190
+ ## Migrating from `@redact-secret/adapter-otel`
191
+
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:
196
+
197
+ ```sh
198
+ npm uninstall @redact-secret/adapter-otel
199
+ npm install @redact-secret/adapter-otel-trace
200
+ ```
201
+
202
+ ```diff
203
+ -import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel";
204
+ +import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel-trace";
205
+ ```
206
+
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.
213
+
214
+ Neither name protects OpenTelemetry Logs, before or after migrating.
215
+
216
+ ## License
217
+
218
+ MIT
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The live OpenTelemetry JS **trace** integration: a `SpanProcessor`. It does
3
+ * not see OpenTelemetry Logs (`LogRecord`s); nothing in this package protects
4
+ * them. It was published as `@redact-secret/adapter-otel` up to `0.1.2`; that
5
+ * name now re-exports this package.
6
+ *
7
+ * ```js
8
+ * import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
9
+ * import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel-trace";
10
+ *
11
+ * const provider = new NodeTracerProvider({
12
+ * spanProcessors: [await createRedactingSpanProcessor(new BatchSpanProcessor(exporter))],
13
+ * });
14
+ * ```
15
+ *
16
+ * `await initialize()` must resolve before `scanAndRedact` is used; this
17
+ * factory enforces that order. It is the only code in the package that loads
18
+ * `@redact-secret/core` at runtime, and does so on call (as
19
+ * `@redact-secret/adapter`'s `createMaskSecrets` does), so importing the
20
+ * injected API never loads the native core.
21
+ *
22
+ * PII detection is opt-in and process-wide in the core. Omit `pii` and this
23
+ * factory initializes the core as before and accepts whatever selection the
24
+ * application already activated, in either order. Pass
25
+ * `createRedactingSpanProcessor(next, { pii: ["pii:global"] })` to activate a
26
+ * selection from here instead, and the factory rejects rather than run with
27
+ * PII off. See `@redact-secret/adapter`'s `activateCore` for the whole rule,
28
+ * including why activation is not the same as masking every PII value.
29
+ */
30
+ import type { SpanProcessor } from "@opentelemetry/sdk-trace-base";
31
+ import { type CoreActivation } from "@redact-secret/adapter";
32
+ import { type RedactingSpanProcessorOptions, RedactingSpanProcessorWith } from "./span-processor.js";
33
+ export type { CoreActivation, MaskLeafOptions } from "@redact-secret/adapter";
34
+ export { type OtelSpanOutcome, type RedactAttributesOptions, type RedactingSpanProcessorOptions, RedactingSpanProcessorWith, redactAttributesWith, } from "./span-processor.js";
35
+ /** {@link RedactingSpanProcessorOptions} plus the live factory's PII activation. */
36
+ export type CreateRedactingSpanProcessorOptions = RedactingSpanProcessorOptions & CoreActivation;
37
+ /**
38
+ * Awaits `initialize()` once, then wraps `next` with the real scanner.
39
+ * `options.onOutcome` reports one input-free summary per span.
40
+ *
41
+ * `options.pii` activates core PII selectors for the whole process. Omit it
42
+ * and an activation the application already made is accepted rather than
43
+ * fought over. Pass it and this rejects — with a fixed message and code, never
44
+ * a selector or a core message — rather than run with PII off.
45
+ */
46
+ export declare function createRedactingSpanProcessor(next: SpanProcessor, options?: CreateRedactingSpanProcessorOptions): Promise<RedactingSpanProcessorWith>;
package/dist/index.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The live OpenTelemetry JS **trace** integration: a `SpanProcessor`. It does
3
+ * not see OpenTelemetry Logs (`LogRecord`s); nothing in this package protects
4
+ * them. It was published as `@redact-secret/adapter-otel` up to `0.1.2`; that
5
+ * name now re-exports this package.
6
+ *
7
+ * ```js
8
+ * import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
9
+ * import { createRedactingSpanProcessor } from "@redact-secret/adapter-otel-trace";
10
+ *
11
+ * const provider = new NodeTracerProvider({
12
+ * spanProcessors: [await createRedactingSpanProcessor(new BatchSpanProcessor(exporter))],
13
+ * });
14
+ * ```
15
+ *
16
+ * `await initialize()` must resolve before `scanAndRedact` is used; this
17
+ * factory enforces that order. It is the only code in the package that loads
18
+ * `@redact-secret/core` at runtime, and does so on call (as
19
+ * `@redact-secret/adapter`'s `createMaskSecrets` does), so importing the
20
+ * injected API never loads the native core.
21
+ *
22
+ * PII detection is opt-in and process-wide in the core. Omit `pii` and this
23
+ * factory initializes the core as before and accepts whatever selection the
24
+ * application already activated, in either order. Pass
25
+ * `createRedactingSpanProcessor(next, { pii: ["pii:global"] })` to activate a
26
+ * selection from here instead, and the factory rejects rather than run with
27
+ * PII off. See `@redact-secret/adapter`'s `activateCore` for the whole rule,
28
+ * including why activation is not the same as masking every PII value.
29
+ */
30
+ import { activateCore } from "@redact-secret/adapter";
31
+ import { RedactingSpanProcessorWith } from "./span-processor.js";
32
+ export { RedactingSpanProcessorWith, redactAttributesWith, } from "./span-processor.js";
33
+ /**
34
+ * Awaits `initialize()` once, then wraps `next` with the real scanner.
35
+ * `options.onOutcome` reports one input-free summary per span.
36
+ *
37
+ * `options.pii` activates core PII selectors for the whole process. Omit it
38
+ * and an activation the application already made is accepted rather than
39
+ * fought over. Pass it and this rejects — with a fixed message and code, never
40
+ * a selector or a core message — rather than run with PII off.
41
+ */
42
+ export async function createRedactingSpanProcessor(next, options = {}) {
43
+ // `pii` is read by property so an inherited activation survives, and
44
+ // `options` is forwarded as the same object so every other inherited key
45
+ // does too. `pii` rides along unread, as any unknown key would.
46
+ const activation = options.pii === undefined ? {} : { pii: options.pii };
47
+ const loaded = await import("@redact-secret/core");
48
+ await activateCore(loaded, activation);
49
+ return new RedactingSpanProcessorWith(next, loaded.scanAndRedact, options);
50
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A `SpanProcessor` (OpenTelemetry JS, pinned against
3
+ * `@opentelemetry/sdk-trace-base@2.11.0`:
4
+ * https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/sdk-trace/src/SpanProcessor.ts)
5
+ * that redacts the span name, every string and string-array attribute —
6
+ * including OpenInference (`llm.input_messages`, `input.value`, ...) and
7
+ * GenAI semantic-convention attributes (`gen_ai.prompt`, ...) — each event's
8
+ * name and attributes, the status message, and each link's attributes,
9
+ * before handing the span to the next processor. It does not
10
+ * allowlist those attribute names: every string-shaped attribute value is
11
+ * scanned, which covers any semantic convention without hardcoding it and
12
+ * without a dependency on either convention's attribute list.
13
+ *
14
+ * `ReadableSpan`'s fields are typed `readonly` but are plain, writable
15
+ * objects at runtime, and `onEnd` writes the masked values back in place.
16
+ * Every write is read back. If one does not take (a frozen bag, a setter
17
+ * that ignores it), the span is dropped — never forwarded unredacted —
18
+ * with a one-time process warning naming the field, never its value.
19
+ * `test/otel-host.test.ts` asserts the writes take effect on a real span,
20
+ * at both ends of the declared SDK range.
21
+ *
22
+ * This file never imports `@opentelemetry/sdk-trace-base` at runtime — the
23
+ * imports below are `import type`, erased at compile time. A
24
+ * `SpanProcessor` is a structural (duck-typed) interface in JS, so
25
+ * wrapping one needs no dependency, and this module is testable with a
26
+ * plain object.
27
+ */
28
+ import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
29
+ import type { MaskLeafOptions, ScanAndRedact, ValueCounts } from "@redact-secret/adapter";
30
+ /**
31
+ * @deprecated Use `MaskLeafOptions` (`{ policy, maxStringLength }`), which
32
+ * this package re-exports; this alias adds nothing and will be removed in a
33
+ * future major version.
34
+ */
35
+ export type RedactAttributesOptions = MaskLeafOptions;
36
+ /**
37
+ * One summary per **span**, the unit a host counts in
38
+ * (redact-secret/redact-secret-adapters#45). Every field is bounded and
39
+ * enumerated; `values`' definitions are in `@redact-secret/adapter`'s
40
+ * `outcome.ts`. No attribute name, key, value or error text is in it.
41
+ */
42
+ export interface OtelSpanOutcome {
43
+ readonly host: "otel";
44
+ readonly unit: "span";
45
+ readonly values: ValueCounts;
46
+ /**
47
+ * `true` when **this processor** did not hand the span to the next one,
48
+ * because a masked value would not write back. It does not mean the span
49
+ * was sampled out, and `false` does not mean the span was exported: whether
50
+ * the next processor kept it and whether an exporter succeeded are things
51
+ * this adapter never learns and does not report.
52
+ */
53
+ readonly dropped: boolean;
54
+ }
55
+ export interface RedactingSpanProcessorOptions extends MaskLeafOptions {
56
+ /**
57
+ * Observational: called once per span, synchronously at the end of `onEnd`,
58
+ * after the span has either been forwarded or dropped. Increment your own
59
+ * counters from it.
60
+ *
61
+ * It cannot change what is exported, and anything it throws is swallowed,
62
+ * never read, and never rethrown — including for a span that was dropped.
63
+ * It is re-entrancy-guarded. No exporter or network client is created for it.
64
+ */
65
+ readonly onOutcome?: (outcome: OtelSpanOutcome) => void;
66
+ }
67
+ /**
68
+ * Mutates `attributes` in place. A no-op for `undefined`/`null`. Throws a
69
+ * `TypeError` naming no value if a masked value cannot be written back.
70
+ */
71
+ export declare function redactAttributesWith(scanAndRedact: ScanAndRedact, attributes: object | null | undefined, options?: MaskLeafOptions): void;
72
+ /**
73
+ * Wraps `next` (any object shaped like a `SpanProcessor`) and redacts each
74
+ * span's free text before delegating to it. `scanAndRedact` is injected so
75
+ * this class is testable without the built native addon; see `./index.ts`
76
+ * for the live factory.
77
+ */
78
+ export declare class RedactingSpanProcessorWith implements SpanProcessor {
79
+ #private;
80
+ constructor(next: SpanProcessor, scanAndRedact: ScanAndRedact, options?: RedactingSpanProcessorOptions);
81
+ onStart(...args: Parameters<SpanProcessor["onStart"]>): void;
82
+ /**
83
+ * Forwards the SDK's optional, experimental `onEnding` (called while the
84
+ * span is still writable). Typed structurally: it is absent from the
85
+ * `SpanProcessor` interface at the low end of the declared SDK range.
86
+ */
87
+ onEnding(span: Span): void;
88
+ /** Never throws: a span that cannot be redacted is dropped, not exported. */
89
+ onEnd(span: ReadableSpan): void;
90
+ shutdown(): Promise<void>;
91
+ forceFlush(): Promise<void>;
92
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * A `SpanProcessor` (OpenTelemetry JS, pinned against
3
+ * `@opentelemetry/sdk-trace-base@2.11.0`:
4
+ * https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/sdk-trace/src/SpanProcessor.ts)
5
+ * that redacts the span name, every string and string-array attribute —
6
+ * including OpenInference (`llm.input_messages`, `input.value`, ...) and
7
+ * GenAI semantic-convention attributes (`gen_ai.prompt`, ...) — each event's
8
+ * name and attributes, the status message, and each link's attributes,
9
+ * before handing the span to the next processor. It does not
10
+ * allowlist those attribute names: every string-shaped attribute value is
11
+ * scanned, which covers any semantic convention without hardcoding it and
12
+ * without a dependency on either convention's attribute list.
13
+ *
14
+ * `ReadableSpan`'s fields are typed `readonly` but are plain, writable
15
+ * objects at runtime, and `onEnd` writes the masked values back in place.
16
+ * Every write is read back. If one does not take (a frozen bag, a setter
17
+ * that ignores it), the span is dropped — never forwarded unredacted —
18
+ * with a one-time process warning naming the field, never its value.
19
+ * `test/otel-host.test.ts` asserts the writes take effect on a real span,
20
+ * at both ends of the declared SDK range.
21
+ *
22
+ * This file never imports `@opentelemetry/sdk-trace-base` at runtime — the
23
+ * imports below are `import type`, erased at compile time. A
24
+ * `SpanProcessor` is a structural (duck-typed) interface in JS, so
25
+ * wrapping one needs no dependency, and this module is testable with a
26
+ * plain object.
27
+ */
28
+ import { countLeaf, createOutcomeCounter, ERROR_MARKER, maskLeafOutcomeWith, notify, toValueCounts, } from "@redact-secret/adapter";
29
+ /** A span field that did not take a masked write. The message names the field, never its value. */
30
+ class UnredactableFieldError extends Error {
31
+ }
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.
35
+ */
36
+ function maskerFor(scanAndRedact, options, counter) {
37
+ return (text) => {
38
+ try {
39
+ const leaf = maskLeafOutcomeWith(scanAndRedact, text, options);
40
+ if (counter !== undefined)
41
+ countLeaf(counter(), leaf);
42
+ return leaf.text;
43
+ }
44
+ catch {
45
+ if (counter !== undefined)
46
+ counter().failed += 1;
47
+ return ERROR_MARKER;
48
+ }
49
+ };
50
+ }
51
+ /** The masked value, or `value` itself when nothing in it changed. */
52
+ function maskAttributeValue(mask, value) {
53
+ if (typeof value === "string")
54
+ return mask(value);
55
+ if (Array.isArray(value)) {
56
+ // OpenTelemetry allows null/undefined holes in a homogeneous array, so
57
+ // every string element is masked and every other element kept in place.
58
+ const masked = value.map((item) => (typeof item === "string" ? mask(item) : item));
59
+ return masked.some((item, index) => item !== value[index]) ? masked : value;
60
+ }
61
+ // Numbers and booleans cannot carry a secret as free text.
62
+ return value;
63
+ }
64
+ /** Writes `value` to `target[key]` if it differs, and throws if the write does not show. */
65
+ function writeBack(target, key, value, field) {
66
+ const record = target;
67
+ if (record[key] === value)
68
+ return;
69
+ try {
70
+ record[key] = value;
71
+ }
72
+ catch {
73
+ throw new UnredactableFieldError(`${field} did not take the masked write`);
74
+ }
75
+ if (record[key] !== value)
76
+ throw new UnredactableFieldError(`${field} did not take the masked write`);
77
+ }
78
+ function redactBag(mask, bag, field) {
79
+ if (bag == null)
80
+ return;
81
+ for (const key of Object.keys(bag)) {
82
+ writeBack(bag, key, maskAttributeValue(mask, bag[key]), field);
83
+ }
84
+ }
85
+ /**
86
+ * Mutates `attributes` in place. A no-op for `undefined`/`null`. Throws a
87
+ * `TypeError` naming no value if a masked value cannot be written back.
88
+ */
89
+ export function redactAttributesWith(scanAndRedact, attributes, options = {}) {
90
+ try {
91
+ redactBag(maskerFor(scanAndRedact, options), attributes, "attributes");
92
+ }
93
+ catch {
94
+ throw new TypeError("redactAttributesWith: a masked attribute could not be written back");
95
+ }
96
+ }
97
+ function redactSpan(mask, span) {
98
+ if (typeof span.name === "string")
99
+ writeBack(span, "name", mask(span.name), "span.name");
100
+ redactBag(mask, span.attributes, "span.attributes");
101
+ const status = span.status;
102
+ if (typeof status?.message === "string") {
103
+ const message = mask(status.message);
104
+ // Replaced, not mutated: the status object may be the caller's own.
105
+ if (message !== status.message)
106
+ writeBack(span, "status", { ...status, message }, "span.status");
107
+ }
108
+ for (const [index, event] of (span.events ?? []).entries()) {
109
+ if (typeof event.name === "string")
110
+ writeBack(event, "name", mask(event.name), `span.events[${index}].name`);
111
+ redactBag(mask, event.attributes, `span.events[${index}].attributes`);
112
+ }
113
+ for (const [index, link] of (span.links ?? []).entries()) {
114
+ redactBag(mask, link.attributes, `span.links[${index}].attributes`);
115
+ }
116
+ }
117
+ /**
118
+ * Wraps `next` (any object shaped like a `SpanProcessor`) and redacts each
119
+ * span's free text before delegating to it. `scanAndRedact` is injected so
120
+ * this class is testable without the built native addon; see `./index.ts`
121
+ * for the live factory.
122
+ */
123
+ export class RedactingSpanProcessorWith {
124
+ #next;
125
+ #mask;
126
+ #onOutcome;
127
+ #counter = createOutcomeCounter();
128
+ #reporting = false;
129
+ #warned = false;
130
+ constructor(next, scanAndRedact, options = {}) {
131
+ if (typeof next?.onEnd !== "function") {
132
+ throw new TypeError("RedactingSpanProcessorWith: next must be a SpanProcessor");
133
+ }
134
+ if (typeof scanAndRedact !== "function") {
135
+ throw new TypeError("RedactingSpanProcessorWith: scanAndRedact must be a function");
136
+ }
137
+ const { onOutcome, ...maskOptions } = options;
138
+ if (onOutcome !== undefined && typeof onOutcome !== "function") {
139
+ throw new TypeError("RedactingSpanProcessorWith: onOutcome must be a function");
140
+ }
141
+ this.#next = next;
142
+ this.#onOutcome = onOutcome;
143
+ this.#mask = maskerFor(scanAndRedact, maskOptions, onOutcome === undefined ? undefined : () => this.#counter);
144
+ }
145
+ onStart(...args) {
146
+ this.#next.onStart?.(...args);
147
+ }
148
+ /**
149
+ * Forwards the SDK's optional, experimental `onEnding` (called while the
150
+ * span is still writable). Typed structurally: it is absent from the
151
+ * `SpanProcessor` interface at the low end of the declared SDK range.
152
+ */
153
+ onEnding(span) {
154
+ this.#next.onEnding?.(span);
155
+ }
156
+ /** Never throws: a span that cannot be redacted is dropped, not exported. */
157
+ onEnd(span) {
158
+ const counting = this.#onOutcome !== undefined;
159
+ // A fresh counter per span, so `onOutcome` reports this span's values and
160
+ // not a running total, and the previous one is restored: a downstream
161
+ // processor may end a span synchronously inside `#next.onEnd` below (a
162
+ // `SimpleSpanProcessor` over an instrumented exporter, or any processor
163
+ // that emits a span of its own), which re-enters this method.
164
+ const outer = this.#counter;
165
+ if (counting)
166
+ this.#counter = createOutcomeCounter();
167
+ let dropped = false;
168
+ let counts;
169
+ try {
170
+ try {
171
+ redactSpan(this.#mask, span);
172
+ }
173
+ catch (error) {
174
+ this.#warnDropped(error instanceof UnredactableFieldError ? error.message : "unexpected span shape");
175
+ dropped = true;
176
+ }
177
+ // Snapshotted before delegating, because this span's numbers are final
178
+ // here and `#report` runs after a nested `onEnd` may have replaced the
179
+ // field.
180
+ if (counting)
181
+ counts = toValueCounts(this.#counter);
182
+ }
183
+ finally {
184
+ this.#counter = outer;
185
+ }
186
+ // Reported whether the span was forwarded or dropped, and after the next
187
+ // processor has had it, so an observer cannot affect what is exported.
188
+ try {
189
+ if (!dropped)
190
+ this.#next.onEnd(span);
191
+ }
192
+ finally {
193
+ if (counts !== undefined)
194
+ this.#report(counts, dropped);
195
+ }
196
+ }
197
+ #report(values, dropped) {
198
+ if (this.#onOutcome === undefined || this.#reporting)
199
+ return;
200
+ this.#reporting = true;
201
+ try {
202
+ notify(this.#onOutcome, { host: "otel", unit: "span", values, dropped });
203
+ }
204
+ finally {
205
+ this.#reporting = false;
206
+ }
207
+ }
208
+ shutdown() {
209
+ return this.#next.shutdown();
210
+ }
211
+ forceFlush() {
212
+ return this.#next.forceFlush ? this.#next.forceFlush() : Promise.resolve();
213
+ }
214
+ #warnDropped(reason) {
215
+ if (this.#warned)
216
+ return;
217
+ this.#warned = true;
218
+ const message = `@redact-secret/adapter-otel: dropped a span that could not be redacted (${reason})`;
219
+ try {
220
+ if (typeof process !== "undefined" && typeof process.emitWarning === "function") {
221
+ process.emitWarning(message, { code: "REDACT_SECRET_SPAN_DROPPED" });
222
+ }
223
+ else {
224
+ console.warn(message);
225
+ }
226
+ }
227
+ catch {
228
+ // A warning is best effort; the span is dropped either way.
229
+ }
230
+ }
231
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@redact-secret/adapter-otel-trace",
3
+ "version": "0.1.0",
4
+ "description": "A redacting OpenTelemetry JS SpanProcessor (traces only) over the Redact Secret core.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "types": "./dist/index.d.ts",
15
+ "files": [
16
+ "dist",
17
+ "CHANGELOG.md",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": "20.x || 22.x || 24.x"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/redact-secret/redact-secret-adapters.git",
27
+ "directory": "packages/adapter-otel-trace"
28
+ },
29
+ "homepage": "https://github.com/redact-secret/redact-secret-adapters/tree/main/packages/adapter-otel-trace#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/redact-secret/redact-secret-adapters/issues"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.build.json",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit"
36
+ },
37
+ "dependencies": {
38
+ "@redact-secret/adapter": "^0.1.3"
39
+ },
40
+ "peerDependencies": {
41
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
42
+ "@redact-secret/core": "^0.1.0-beta.6"
43
+ },
44
+ "devDependencies": {
45
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
46
+ "@redact-secret/core": "^0.1.0-beta.6",
47
+ "@opentelemetry/otlp-transformer": "^0.222.0"
48
+ }
49
+ }