@graphorin/observability 0.6.0 → 0.7.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 +42 -0
- package/README.md +29 -5
- package/dist/cost/cost-tracker.d.ts.map +1 -1
- package/dist/cost/cost-tracker.js +34 -0
- package/dist/cost/cost-tracker.js.map +1 -1
- package/dist/cost/delegate.d.ts +40 -0
- package/dist/cost/delegate.d.ts.map +1 -0
- package/dist/cost/delegate.js +31 -0
- package/dist/cost/delegate.js.map +1 -0
- package/dist/cost/index.d.ts +2 -1
- package/dist/cost/index.js +2 -1
- package/dist/cost/types.d.ts +39 -0
- package/dist/cost/types.d.ts.map +1 -1
- package/dist/exporters/otlp-http.d.ts.map +1 -1
- package/dist/exporters/otlp-http.js +4 -2
- package/dist/exporters/otlp-http.js.map +1 -1
- package/dist/exporters/types.d.ts +7 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/types.js.map +1 -1
- package/dist/exporters/with-validation.d.ts.map +1 -1
- package/dist/exporters/with-validation.js +5 -4
- package/dist/exporters/with-validation.js.map +1 -1
- package/dist/gen-ai/emit.js +9 -1
- package/dist/gen-ai/emit.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -10
- package/dist/index.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/redaction/imperative-patterns.d.ts +3 -3
- package/dist/redaction/imperative-patterns.d.ts.map +1 -1
- package/dist/redaction/imperative-patterns.js +7 -0
- package/dist/redaction/imperative-patterns.js.map +1 -1
- package/dist/redaction/patterns.d.ts.map +1 -1
- package/dist/redaction/patterns.js +2 -2
- package/dist/redaction/patterns.js.map +1 -1
- package/dist/tracer/sampling.d.ts +6 -1
- package/dist/tracer/sampling.d.ts.map +1 -1
- package/dist/tracer/sampling.js +7 -1
- package/dist/tracer/sampling.js.map +1 -1
- package/dist/tracer/span.d.ts.map +1 -1
- package/dist/tracer/span.js +12 -3
- package/dist/tracer/span.js.map +1 -1
- package/package.json +18 -35
- package/src/cost/cost-tracker.ts +315 -0
- package/src/cost/delegate.ts +79 -0
- package/src/cost/index.ts +23 -0
- package/src/cost/types.ts +151 -0
- package/src/eval/index.ts +16 -0
- package/src/eval/runner.ts +146 -0
- package/src/eval/types.ts +122 -0
- package/src/exporters/console.ts +75 -0
- package/src/exporters/index.ts +36 -0
- package/src/exporters/jsonl.ts +167 -0
- package/src/exporters/otlp-http.ts +178 -0
- package/src/exporters/types.ts +85 -0
- package/src/exporters/with-validation.ts +176 -0
- package/src/gen-ai/emit.ts +157 -0
- package/src/gen-ai/index.ts +26 -0
- package/src/gen-ai/operation-mapping.ts +89 -0
- package/src/gen-ai/system-derivation.ts +84 -0
- package/src/gen-ai/types.ts +143 -0
- package/src/index.ts +36 -0
- package/src/logger/index.ts +13 -0
- package/src/logger/logger.ts +178 -0
- package/src/openinference/index.ts +133 -0
- package/src/redaction/config.ts +50 -0
- package/src/redaction/errors.ts +53 -0
- package/src/redaction/imperative-patterns.ts +268 -0
- package/src/redaction/index.ts +42 -0
- package/src/redaction/patterns.ts +263 -0
- package/src/redaction/types.ts +116 -0
- package/src/redaction/validator.ts +302 -0
- package/src/replay/config.ts +58 -0
- package/src/replay/index.ts +17 -0
- package/src/replay/log.ts +89 -0
- package/src/replay/replay.ts +196 -0
- package/src/replay/types.ts +112 -0
- package/src/telemetry/index.ts +94 -0
- package/src/tracer/ids.ts +27 -0
- package/src/tracer/index.ts +22 -0
- package/src/tracer/sampling.ts +170 -0
- package/src/tracer/span-names.ts +52 -0
- package/src/tracer/span.ts +175 -0
- package/src/tracer/tracer.ts +309 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the {@link RedactionValidator} surface.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RedactionInput, RedactionOutput, RedactionValidator } from '@graphorin/core';
|
|
8
|
+
|
|
9
|
+
import type { RedactionPattern } from './patterns.js';
|
|
10
|
+
|
|
11
|
+
export type { RedactionInput, RedactionOutput, RedactionValidator } from '@graphorin/core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Optional sink invoked every time the validator drops a value or
|
|
15
|
+
* masks a pattern. Useful for emitting custom metrics, audit entries,
|
|
16
|
+
* or alert hooks. The callback receives only sanitized data - secret
|
|
17
|
+
* values are never forwarded.
|
|
18
|
+
*
|
|
19
|
+
* @stable
|
|
20
|
+
*/
|
|
21
|
+
export type RedactionViolationCallback = (violation: RedactionViolation) => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitized record describing a single redaction event. Never carries
|
|
25
|
+
* the secret value itself; only metadata that is safe to log.
|
|
26
|
+
*
|
|
27
|
+
* @stable
|
|
28
|
+
*/
|
|
29
|
+
export interface RedactionViolation {
|
|
30
|
+
readonly reason:
|
|
31
|
+
| 'sensitivity-tier-exceeded'
|
|
32
|
+
| 'pii-pattern-match'
|
|
33
|
+
| 'secret-pattern-match'
|
|
34
|
+
| 'unredacted-secret-value'
|
|
35
|
+
| 'invalid-input';
|
|
36
|
+
readonly attribute?: string;
|
|
37
|
+
readonly spanType?: string;
|
|
38
|
+
readonly origin?: string;
|
|
39
|
+
/** Pattern names that fired (if applicable). */
|
|
40
|
+
readonly patterns?: ReadonlyArray<string>;
|
|
41
|
+
/** Tier declared by the upstream caller. */
|
|
42
|
+
readonly declaredTier?: RedactionInput['tier'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Counter exposed via {@link createRedactionValidator}. Implementations
|
|
47
|
+
* keep counters in-memory; downstream code can scrape them and convert
|
|
48
|
+
* to Prometheus metrics.
|
|
49
|
+
*
|
|
50
|
+
* @stable
|
|
51
|
+
*/
|
|
52
|
+
export interface RedactionCounters {
|
|
53
|
+
/** Total values dropped by the validator. */
|
|
54
|
+
droppedTotal: number;
|
|
55
|
+
/** Drops by reason; the four built-in reasons + any custom ones. */
|
|
56
|
+
droppedByReason: Readonly<Record<string, number>>;
|
|
57
|
+
/** Pattern-matching counters keyed by pattern name. */
|
|
58
|
+
matchesByPattern: Readonly<Record<string, number>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Configuration shape for {@link createRedactionValidator}.
|
|
63
|
+
*
|
|
64
|
+
* @stable
|
|
65
|
+
*/
|
|
66
|
+
export interface RedactionValidatorOptions {
|
|
67
|
+
/** Identifier reported via `validator.id`. Defaults to `'default'`. */
|
|
68
|
+
readonly id?: string;
|
|
69
|
+
/** Lowest tier that may pass through the validator. Default: `'public'`. */
|
|
70
|
+
readonly minTier?: RedactionInput['tier'];
|
|
71
|
+
/**
|
|
72
|
+
* When `true`, throw a {@link RedactionValidationError} on any drop.
|
|
73
|
+
* Useful in tests; production should keep this off so the validator
|
|
74
|
+
* silently drops + counts.
|
|
75
|
+
*/
|
|
76
|
+
readonly failOnUnredactedSensitive?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Pattern catalogue. Defaults to the 14 built-in default-on
|
|
79
|
+
* patterns. Custom patterns can extend or replace this list.
|
|
80
|
+
*/
|
|
81
|
+
readonly patterns?: ReadonlyArray<RedactionPattern>;
|
|
82
|
+
/**
|
|
83
|
+
* Per-name allow-list. When provided, only patterns whose `name`
|
|
84
|
+
* appears here are evaluated. Empty array disables all pattern
|
|
85
|
+
* matching (tier filtering still applies).
|
|
86
|
+
*/
|
|
87
|
+
readonly enabledPatterns?: ReadonlyArray<string>;
|
|
88
|
+
/**
|
|
89
|
+
* Per-name deny-list. Patterns listed here are skipped entirely.
|
|
90
|
+
* Applied **after** `enabledPatterns`.
|
|
91
|
+
*/
|
|
92
|
+
readonly disabledPatterns?: ReadonlyArray<string>;
|
|
93
|
+
/**
|
|
94
|
+
* Optional sink invoked on every violation. Receives only sanitized
|
|
95
|
+
* data; never receives secret values.
|
|
96
|
+
*/
|
|
97
|
+
readonly onViolation?: RedactionViolationCallback;
|
|
98
|
+
/**
|
|
99
|
+
* Optional pluggable additional check, fired after the pattern
|
|
100
|
+
* scan succeeds. The callback returns `null` to drop the value or a
|
|
101
|
+
* sanitized {@link RedactionOutput} to forward.
|
|
102
|
+
*/
|
|
103
|
+
readonly customValidator?: (input: RedactionInput) => RedactionOutput | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Concrete validator returned by {@link createRedactionValidator}.
|
|
108
|
+
*
|
|
109
|
+
* @stable
|
|
110
|
+
*/
|
|
111
|
+
export interface RedactionValidatorInstance extends RedactionValidator {
|
|
112
|
+
/** Snapshot of internal counters. Returned object is a fresh copy. */
|
|
113
|
+
readonly counters: () => RedactionCounters;
|
|
114
|
+
/** Reset all counters back to zero. */
|
|
115
|
+
readonly resetCounters: () => void;
|
|
116
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createRedactionValidator(...)` - the building block for the
|
|
3
|
+
* mandatory `withValidation()` wrapper applied to every exporter.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SENSITIVITY_ORDER, type Sensitivity } from '@graphorin/core';
|
|
9
|
+
|
|
10
|
+
import { RedactionValidationError } from './errors.js';
|
|
11
|
+
import { BUILT_IN_PATTERNS, type RedactionPattern } from './patterns.js';
|
|
12
|
+
import type {
|
|
13
|
+
RedactionCounters,
|
|
14
|
+
RedactionInput,
|
|
15
|
+
RedactionOutput,
|
|
16
|
+
RedactionValidator,
|
|
17
|
+
RedactionValidatorInstance,
|
|
18
|
+
RedactionValidatorOptions,
|
|
19
|
+
RedactionViolation,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
const TIER_INDEX = new Map<Sensitivity, number>(
|
|
23
|
+
SENSITIVITY_ORDER.map((tier, idx) => [tier, idx] as const),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function rank(tier: Sensitivity): number {
|
|
27
|
+
const idx = TIER_INDEX.get(tier);
|
|
28
|
+
return idx ?? 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface CountersInternal {
|
|
32
|
+
droppedTotal: number;
|
|
33
|
+
droppedByReason: Map<string, number>;
|
|
34
|
+
matchesByPattern: Map<string, number>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeCounters(): CountersInternal {
|
|
38
|
+
return {
|
|
39
|
+
droppedTotal: 0,
|
|
40
|
+
droppedByReason: new Map(),
|
|
41
|
+
matchesByPattern: new Map(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bump(map: Map<string, number>, key: string): void {
|
|
46
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function freezeCounters(c: CountersInternal): RedactionCounters {
|
|
50
|
+
return {
|
|
51
|
+
droppedTotal: c.droppedTotal,
|
|
52
|
+
droppedByReason: Object.freeze(Object.fromEntries(c.droppedByReason)),
|
|
53
|
+
matchesByPattern: Object.freeze(Object.fromEntries(c.matchesByPattern)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function selectPatterns(
|
|
58
|
+
patterns: ReadonlyArray<RedactionPattern>,
|
|
59
|
+
enabled?: ReadonlyArray<string>,
|
|
60
|
+
disabled?: ReadonlyArray<string>,
|
|
61
|
+
): RedactionPattern[] {
|
|
62
|
+
const enabledSet = enabled === undefined ? null : new Set(enabled);
|
|
63
|
+
const disabledSet = new Set(disabled ?? []);
|
|
64
|
+
const out: RedactionPattern[] = [];
|
|
65
|
+
for (const p of patterns) {
|
|
66
|
+
if (enabledSet !== null && !enabledSet.has(p.name)) continue;
|
|
67
|
+
if (disabledSet.has(p.name)) continue;
|
|
68
|
+
out.push(p);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk every string discovered in `value` and apply `fn`. Returns
|
|
75
|
+
* `{ output, matched }` where `output` is the rewritten payload and
|
|
76
|
+
* `matched` is the de-duplicated list of pattern names that fired.
|
|
77
|
+
*
|
|
78
|
+
* The walker handles strings, plain objects, and arrays. Other JS
|
|
79
|
+
* primitives are passed through untouched. Cycles are detected via a
|
|
80
|
+
* `WeakSet` and broken with `'[Circular]'`.
|
|
81
|
+
*/
|
|
82
|
+
function walk(
|
|
83
|
+
value: unknown,
|
|
84
|
+
fn: (s: string, matched: Set<string>) => string,
|
|
85
|
+
matched: Set<string>,
|
|
86
|
+
seen: WeakSet<object>,
|
|
87
|
+
): unknown {
|
|
88
|
+
if (typeof value === 'string') return fn(value, matched);
|
|
89
|
+
if (value === null || typeof value !== 'object') return value;
|
|
90
|
+
if (seen.has(value as object)) return '[Circular]';
|
|
91
|
+
seen.add(value as object);
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
const out = new Array<unknown>(value.length);
|
|
95
|
+
for (let i = 0; i < value.length; i++) {
|
|
96
|
+
out[i] = walk(value[i], fn, matched, seen);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const obj = value as Record<string, unknown>;
|
|
102
|
+
const out: Record<string, unknown> = {};
|
|
103
|
+
for (const key of Object.keys(obj)) {
|
|
104
|
+
out[key] = walk(obj[key], fn, matched, seen);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply every active pattern to `s`. Mutates `matched` with the names
|
|
111
|
+
* that fired and returns the rewritten string.
|
|
112
|
+
*/
|
|
113
|
+
function applyPatterns(
|
|
114
|
+
s: string,
|
|
115
|
+
patterns: ReadonlyArray<RedactionPattern>,
|
|
116
|
+
matched: Set<string>,
|
|
117
|
+
): string {
|
|
118
|
+
let out = s;
|
|
119
|
+
for (const p of patterns) {
|
|
120
|
+
const mask = p.mask ?? `[REDACTED ${p.name}]`;
|
|
121
|
+
p.regex.lastIndex = 0;
|
|
122
|
+
if (p.verify === undefined) {
|
|
123
|
+
if (p.regex.test(out)) {
|
|
124
|
+
matched.add(p.name);
|
|
125
|
+
p.regex.lastIndex = 0;
|
|
126
|
+
out = out.replace(p.regex, mask);
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// RP-21: per-match predicate - only mask hits the verifier accepts (e.g.
|
|
131
|
+
// Luhn-valid PANs), and only count the pattern as matched when one did.
|
|
132
|
+
const verify = p.verify;
|
|
133
|
+
p.regex.lastIndex = 0;
|
|
134
|
+
out = out.replace(p.regex, (m) => {
|
|
135
|
+
if (verify(m)) {
|
|
136
|
+
matched.add(p.name);
|
|
137
|
+
return mask;
|
|
138
|
+
}
|
|
139
|
+
return m;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function violationFor(
|
|
146
|
+
reason: RedactionViolation['reason'],
|
|
147
|
+
input: RedactionInput,
|
|
148
|
+
patterns?: ReadonlyArray<string>,
|
|
149
|
+
): RedactionViolation {
|
|
150
|
+
const baseContext = input.context;
|
|
151
|
+
const base: RedactionViolation = {
|
|
152
|
+
reason,
|
|
153
|
+
declaredTier: input.tier,
|
|
154
|
+
...(baseContext?.attribute === undefined ? {} : { attribute: baseContext.attribute }),
|
|
155
|
+
...(baseContext?.spanType === undefined ? {} : { spanType: baseContext.spanType }),
|
|
156
|
+
...(baseContext?.origin === undefined ? {} : { origin: baseContext.origin }),
|
|
157
|
+
...(patterns === undefined || patterns.length === 0 ? {} : { patterns }),
|
|
158
|
+
};
|
|
159
|
+
return base;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a {@link RedactionValidator} configured against the supplied
|
|
164
|
+
* options. The result implements both the `RedactionValidator`
|
|
165
|
+
* contract from `@graphorin/core` and the
|
|
166
|
+
* {@link RedactionValidatorInstance} extension surface (counters +
|
|
167
|
+
* reset).
|
|
168
|
+
*
|
|
169
|
+
* @stable
|
|
170
|
+
*/
|
|
171
|
+
export function createRedactionValidator(
|
|
172
|
+
opts: RedactionValidatorOptions = {},
|
|
173
|
+
): RedactionValidatorInstance {
|
|
174
|
+
const id = opts.id ?? 'default';
|
|
175
|
+
const minTier: Sensitivity = opts.minTier ?? 'public';
|
|
176
|
+
const failOnUnredacted = opts.failOnUnredactedSensitive === true;
|
|
177
|
+
const patterns = selectPatterns(
|
|
178
|
+
opts.patterns ?? BUILT_IN_PATTERNS,
|
|
179
|
+
opts.enabledPatterns,
|
|
180
|
+
opts.disabledPatterns,
|
|
181
|
+
);
|
|
182
|
+
const onViolation = opts.onViolation;
|
|
183
|
+
const custom = opts.customValidator;
|
|
184
|
+
|
|
185
|
+
let counters = makeCounters();
|
|
186
|
+
|
|
187
|
+
function recordViolation(violation: RedactionViolation): void {
|
|
188
|
+
counters.droppedTotal += 1;
|
|
189
|
+
bump(counters.droppedByReason, violation.reason);
|
|
190
|
+
for (const name of violation.patterns ?? []) {
|
|
191
|
+
bump(counters.matchesByPattern, name);
|
|
192
|
+
}
|
|
193
|
+
if (onViolation !== undefined) {
|
|
194
|
+
try {
|
|
195
|
+
onViolation(violation);
|
|
196
|
+
} catch {
|
|
197
|
+
// Listener errors must never break the exporter.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function dropOrThrow(_input: RedactionInput, violation: RedactionViolation): null {
|
|
203
|
+
recordViolation(violation);
|
|
204
|
+
if (failOnUnredacted) {
|
|
205
|
+
throw new RedactionValidationError(
|
|
206
|
+
`RedactionValidator dropped value (${violation.reason}) - set ` +
|
|
207
|
+
'failOnUnredactedSensitive: false in production to keep the ' +
|
|
208
|
+
'pipeline running.',
|
|
209
|
+
violation,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const validator: RedactionValidator = {
|
|
216
|
+
id,
|
|
217
|
+
minTier,
|
|
218
|
+
validate(input: RedactionInput): RedactionOutput | null {
|
|
219
|
+
if (input === null || input === undefined || typeof input !== 'object') {
|
|
220
|
+
return dropOrThrow(
|
|
221
|
+
input as RedactionInput,
|
|
222
|
+
violationFor('invalid-input', input as RedactionInput),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const declared = input.tier ?? 'internal';
|
|
227
|
+
|
|
228
|
+
if (rank(declared) > rank(minTier)) {
|
|
229
|
+
return dropOrThrow(input, violationFor('sensitivity-tier-exceeded', input));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const matched = new Set<string>();
|
|
233
|
+
const rewritten = walk(
|
|
234
|
+
input.value,
|
|
235
|
+
(s, m) => applyPatterns(s, patterns, m),
|
|
236
|
+
matched,
|
|
237
|
+
new WeakSet(),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (matched.size > 0) {
|
|
241
|
+
const matchedList = [...matched];
|
|
242
|
+
for (const name of matchedList) {
|
|
243
|
+
bump(counters.matchesByPattern, name);
|
|
244
|
+
}
|
|
245
|
+
const containsSecret = matchedList.some(
|
|
246
|
+
(name) => patterns.find((p) => p.name === name)?.category === 'secret',
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (containsSecret) {
|
|
250
|
+
if (failOnUnredacted) {
|
|
251
|
+
const violation = violationFor('secret-pattern-match', input, matchedList);
|
|
252
|
+
counters.droppedTotal += 1;
|
|
253
|
+
bump(counters.droppedByReason, violation.reason);
|
|
254
|
+
if (onViolation !== undefined) {
|
|
255
|
+
try {
|
|
256
|
+
onViolation(violation);
|
|
257
|
+
} catch {
|
|
258
|
+
/* listener errors swallowed */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw new RedactionValidationError(
|
|
262
|
+
`RedactionValidator detected secret pattern(s) in attribute (${matchedList.join(', ')}).`,
|
|
263
|
+
violation,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const out: RedactionOutput =
|
|
270
|
+
matched.size === 0
|
|
271
|
+
? { value: rewritten, tier: declared }
|
|
272
|
+
: { value: rewritten, tier: declared, matched: [...matched] };
|
|
273
|
+
|
|
274
|
+
if (custom !== undefined) {
|
|
275
|
+
return custom({ ...input, value: rewritten });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return out;
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const instance: RedactionValidatorInstance = {
|
|
283
|
+
...validator,
|
|
284
|
+
counters: () => freezeCounters(counters),
|
|
285
|
+
resetCounters: () => {
|
|
286
|
+
counters = makeCounters();
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return instance;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Quickly compute the relative ordering of two sensitivity tiers.
|
|
295
|
+
* Exposed because the tracer + replay layers need it without taking a
|
|
296
|
+
* full dependency on the validator implementation.
|
|
297
|
+
*
|
|
298
|
+
* @stable
|
|
299
|
+
*/
|
|
300
|
+
export function compareSensitivityTiers(a: Sensitivity, b: Sensitivity): number {
|
|
301
|
+
return rank(a) - rank(b);
|
|
302
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration shapes for the replay log. The shapes mirror the
|
|
3
|
+
* canonical `observability.replayLog.*` settings so consumer
|
|
4
|
+
* configuration files can use a single typed structure.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shape consumed by `observability.replayLog.*`.
|
|
11
|
+
*
|
|
12
|
+
* @stable
|
|
13
|
+
*/
|
|
14
|
+
export interface ReplayLogConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Root directory for the JSONL trace files. Required when the
|
|
17
|
+
* replay log is enabled.
|
|
18
|
+
*/
|
|
19
|
+
readonly path: string;
|
|
20
|
+
/**
|
|
21
|
+
* Retention window in days. `0` keeps every file forever.
|
|
22
|
+
*
|
|
23
|
+
* @default 30
|
|
24
|
+
*/
|
|
25
|
+
readonly retentionDays?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Auto-prune hint. `enabled` + `schedule` describe a daily prune of files
|
|
28
|
+
* older than `retentionDays`, but **no built-in scheduler consumes this
|
|
29
|
+
* yet** (RP-19) - it is a declarative intent. Until a host wires it to a
|
|
30
|
+
* trigger, callers must run `pruneTraces(...)` themselves; the default is
|
|
31
|
+
* therefore `enabled: false` so the option is not an inert default-on.
|
|
32
|
+
*
|
|
33
|
+
* @default { enabled: false, schedule: '0 4 * * *' }
|
|
34
|
+
*/
|
|
35
|
+
readonly autoPrune?: {
|
|
36
|
+
readonly enabled: boolean;
|
|
37
|
+
readonly schedule?: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Encryption-at-rest toggle. `'off'` (default) writes plain JSONL;
|
|
41
|
+
* the opt-in `'aes256gcm'` mode hooks into the encryption-at-rest
|
|
42
|
+
* passphrase chain (Phase 16 deliverable).
|
|
43
|
+
*
|
|
44
|
+
* @default 'off'
|
|
45
|
+
*/
|
|
46
|
+
readonly encryption?: 'off' | 'aes256gcm';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default values for the replay log configuration.
|
|
51
|
+
*
|
|
52
|
+
* @stable
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_REPLAY_LOG_CONFIG: Omit<Required<ReplayLogConfig>, 'path'> = Object.freeze({
|
|
55
|
+
retentionDays: 30,
|
|
56
|
+
autoPrune: Object.freeze({ enabled: false, schedule: '0 4 * * *' }),
|
|
57
|
+
encryption: 'off' as const,
|
|
58
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitized-by-default replay surface.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DEFAULT_REPLAY_LOG_CONFIG, type ReplayLogConfig } from './config.js';
|
|
8
|
+
export { getTraceLog, type PruneTracesOptions, pruneTraces } from './log.js';
|
|
9
|
+
export { createReplay, type Replay } from './replay.js';
|
|
10
|
+
export type {
|
|
11
|
+
ReplayAuditBridge,
|
|
12
|
+
ReplayAuditEvent,
|
|
13
|
+
ReplayEvent,
|
|
14
|
+
ReplayMode,
|
|
15
|
+
ReplayOptions,
|
|
16
|
+
ReplayRunInput,
|
|
17
|
+
} from './types.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getTraceLog(...)` / `pruneTraces(...)` - minimal helpers for reading
|
|
3
|
+
* back the JSONL files produced by {@link createJSONLExporter} and
|
|
4
|
+
* pruning old files based on retention policy.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, readFile, stat, unlink } from 'node:fs/promises';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import type { SpanRecord } from '../exporters/types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read every span record from a JSONL trace log. Lines that fail to parse are
|
|
16
|
+
* skipped (the iterator keeps going); the generator only ever yields parsed
|
|
17
|
+
* {@link SpanRecord} values, never `null`.
|
|
18
|
+
*
|
|
19
|
+
* @stable
|
|
20
|
+
*/
|
|
21
|
+
export async function* getTraceLog(filePath: string): AsyncIterable<SpanRecord> {
|
|
22
|
+
const data = await readFile(filePath, 'utf8');
|
|
23
|
+
for (const line of data.split('\n')) {
|
|
24
|
+
if (line.trim() === '') continue;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(line) as SpanRecord;
|
|
27
|
+
yield parsed;
|
|
28
|
+
} catch {
|
|
29
|
+
// Skip malformed lines - replay must keep going.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Configuration shape for {@link pruneTraces}.
|
|
36
|
+
*
|
|
37
|
+
* @stable
|
|
38
|
+
*/
|
|
39
|
+
export interface PruneTracesOptions {
|
|
40
|
+
/** Root directory housing the JSONL files. */
|
|
41
|
+
readonly root: string;
|
|
42
|
+
/** Files older than `olderThanDays` are deleted. `0` keeps every file. */
|
|
43
|
+
readonly olderThanDays: number;
|
|
44
|
+
/**
|
|
45
|
+
* Wall clock used to compute the threshold. Defaults to `Date.now`.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
readonly now?: () => number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove every JSONL file that is older than the configured retention
|
|
54
|
+
* window. Returns the deleted files for caller-side accounting.
|
|
55
|
+
*
|
|
56
|
+
* @stable
|
|
57
|
+
*/
|
|
58
|
+
export async function pruneTraces(opts: PruneTracesOptions): Promise<ReadonlyArray<string>> {
|
|
59
|
+
if (opts.olderThanDays <= 0) return [];
|
|
60
|
+
|
|
61
|
+
const now = opts.now ?? (() => Date.now());
|
|
62
|
+
const threshold = now() - opts.olderThanDays * 24 * 60 * 60 * 1000;
|
|
63
|
+
const root = resolve(opts.root);
|
|
64
|
+
const removed: string[] = [];
|
|
65
|
+
|
|
66
|
+
for await (const filePath of walkJsonl(root)) {
|
|
67
|
+
const info = await stat(filePath);
|
|
68
|
+
if (info.mtimeMs < threshold) {
|
|
69
|
+
await unlink(filePath);
|
|
70
|
+
removed.push(filePath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return removed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function* walkJsonl(root: string): AsyncIterable<string> {
|
|
78
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const full = join(root, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
yield* walkJsonl(full);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (entry.isFile() && full.endsWith('.jsonl')) {
|
|
86
|
+
yield full;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|