@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +29 -5
  3. package/dist/cost/cost-tracker.d.ts.map +1 -1
  4. package/dist/cost/cost-tracker.js +34 -0
  5. package/dist/cost/cost-tracker.js.map +1 -1
  6. package/dist/cost/delegate.d.ts +40 -0
  7. package/dist/cost/delegate.d.ts.map +1 -0
  8. package/dist/cost/delegate.js +31 -0
  9. package/dist/cost/delegate.js.map +1 -0
  10. package/dist/cost/index.d.ts +2 -1
  11. package/dist/cost/index.js +2 -1
  12. package/dist/cost/types.d.ts +39 -0
  13. package/dist/cost/types.d.ts.map +1 -1
  14. package/dist/exporters/otlp-http.d.ts.map +1 -1
  15. package/dist/exporters/otlp-http.js +4 -2
  16. package/dist/exporters/otlp-http.js.map +1 -1
  17. package/dist/exporters/types.d.ts +7 -0
  18. package/dist/exporters/types.d.ts.map +1 -1
  19. package/dist/exporters/types.js.map +1 -1
  20. package/dist/exporters/with-validation.d.ts.map +1 -1
  21. package/dist/exporters/with-validation.js +5 -4
  22. package/dist/exporters/with-validation.js.map +1 -1
  23. package/dist/gen-ai/emit.js +9 -1
  24. package/dist/gen-ai/emit.js.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -10
  28. package/dist/index.js.map +1 -1
  29. package/dist/package.js +6 -0
  30. package/dist/package.js.map +1 -0
  31. package/dist/redaction/imperative-patterns.d.ts +3 -3
  32. package/dist/redaction/imperative-patterns.d.ts.map +1 -1
  33. package/dist/redaction/imperative-patterns.js +7 -0
  34. package/dist/redaction/imperative-patterns.js.map +1 -1
  35. package/dist/redaction/patterns.d.ts.map +1 -1
  36. package/dist/redaction/patterns.js +2 -2
  37. package/dist/redaction/patterns.js.map +1 -1
  38. package/dist/tracer/sampling.d.ts +6 -1
  39. package/dist/tracer/sampling.d.ts.map +1 -1
  40. package/dist/tracer/sampling.js +7 -1
  41. package/dist/tracer/sampling.js.map +1 -1
  42. package/dist/tracer/span.d.ts.map +1 -1
  43. package/dist/tracer/span.js +12 -3
  44. package/dist/tracer/span.js.map +1 -1
  45. package/package.json +18 -35
  46. package/src/cost/cost-tracker.ts +315 -0
  47. package/src/cost/delegate.ts +79 -0
  48. package/src/cost/index.ts +23 -0
  49. package/src/cost/types.ts +151 -0
  50. package/src/eval/index.ts +16 -0
  51. package/src/eval/runner.ts +146 -0
  52. package/src/eval/types.ts +122 -0
  53. package/src/exporters/console.ts +75 -0
  54. package/src/exporters/index.ts +36 -0
  55. package/src/exporters/jsonl.ts +167 -0
  56. package/src/exporters/otlp-http.ts +178 -0
  57. package/src/exporters/types.ts +85 -0
  58. package/src/exporters/with-validation.ts +176 -0
  59. package/src/gen-ai/emit.ts +157 -0
  60. package/src/gen-ai/index.ts +26 -0
  61. package/src/gen-ai/operation-mapping.ts +89 -0
  62. package/src/gen-ai/system-derivation.ts +84 -0
  63. package/src/gen-ai/types.ts +143 -0
  64. package/src/index.ts +36 -0
  65. package/src/logger/index.ts +13 -0
  66. package/src/logger/logger.ts +178 -0
  67. package/src/openinference/index.ts +133 -0
  68. package/src/redaction/config.ts +50 -0
  69. package/src/redaction/errors.ts +53 -0
  70. package/src/redaction/imperative-patterns.ts +268 -0
  71. package/src/redaction/index.ts +42 -0
  72. package/src/redaction/patterns.ts +263 -0
  73. package/src/redaction/types.ts +116 -0
  74. package/src/redaction/validator.ts +302 -0
  75. package/src/replay/config.ts +58 -0
  76. package/src/replay/index.ts +17 -0
  77. package/src/replay/log.ts +89 -0
  78. package/src/replay/replay.ts +196 -0
  79. package/src/replay/types.ts +112 -0
  80. package/src/telemetry/index.ts +94 -0
  81. package/src/tracer/ids.ts +27 -0
  82. package/src/tracer/index.ts +22 -0
  83. package/src/tracer/sampling.ts +170 -0
  84. package/src/tracer/span-names.ts +52 -0
  85. package/src/tracer/span.ts +175 -0
  86. 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
+ }