@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,16 @@
1
+ /**
2
+ * Minimal inline eval runner.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export { runEval } from './runner.js';
8
+ export type {
9
+ Case,
10
+ Dataset,
11
+ EvalCaseResult,
12
+ EvalReport,
13
+ RunEvalOptions,
14
+ ScoreResult,
15
+ Scorer,
16
+ } from './types.js';
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `runEval(...)` - minimal inline eval runner. Walks every case in
3
+ * the dataset, calls `agent.run(case.input)`, applies every supplied
4
+ * scorer, and returns an aggregated {@link EvalReport}.
5
+ *
6
+ * The runner is deliberately tiny - it has no parallelism, no
7
+ * retries, no reporters, no dataset loaders. Production scenarios
8
+ * that need any of those should `pnpm add @graphorin/evals` (post-MVP).
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import type {
14
+ Case,
15
+ EvalCaseResult,
16
+ EvalReport,
17
+ RunEvalOptions,
18
+ ScoreResult,
19
+ Scorer,
20
+ } from './types.js';
21
+
22
+ /**
23
+ * @stable
24
+ */
25
+ export async function runEval<I, O>(opts: RunEvalOptions<I, O>): Promise<EvalReport<I, O>> {
26
+ const iterations = Math.max(1, opts.iterations ?? 1);
27
+ const signal = opts.signal;
28
+ const results: EvalCaseResult<I, O>[] = [];
29
+
30
+ for (let iter = 0; iter < iterations; iter++) {
31
+ for (let idx = 0; idx < opts.dataset.cases.length; idx++) {
32
+ throwIfAborted(signal);
33
+ const sampleCase = opts.dataset.cases[idx];
34
+ if (sampleCase === undefined) continue;
35
+ // EB-6: disambiguate a caller-provided id per iteration too, not just the
36
+ // synthetic fallback - otherwise iterations>1 emits duplicate caseIds.
37
+ const baseId = sampleCase.id ?? `case-${idx}`;
38
+ const caseId = iterations === 1 ? baseId : `${baseId}-iter-${iter}`;
39
+
40
+ const startedAt = Date.now();
41
+ const output = await opts.agent.run(sampleCase.input);
42
+ const durationMs = Date.now() - startedAt;
43
+
44
+ const scores: EvalCaseResult<I, O>['scores'][number][] = [];
45
+ for (const scorer of opts.scorers) {
46
+ throwIfAborted(signal);
47
+ const result = await safeScore(scorer, sampleCase, output, durationMs);
48
+ scores.push({ scorer: scorer.name, result });
49
+ }
50
+
51
+ results.push({
52
+ caseId,
53
+ input: sampleCase.input,
54
+ output,
55
+ durationMs,
56
+ scores,
57
+ });
58
+ }
59
+ }
60
+
61
+ return summarize(results, opts.scorers);
62
+ }
63
+
64
+ async function safeScore<I, O>(
65
+ scorer: Scorer<I, O>,
66
+ c: Case<I, O>,
67
+ output: O,
68
+ durationMs: number,
69
+ ): Promise<ScoreResult> {
70
+ try {
71
+ return await scorer.score({ case: c, output, durationMs });
72
+ } catch (err) {
73
+ return {
74
+ pass: false,
75
+ reason: `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`,
76
+ };
77
+ }
78
+ }
79
+
80
+ function summarize<I, O>(
81
+ results: ReadonlyArray<EvalCaseResult<I, O>>,
82
+ scorers: ReadonlyArray<Scorer<I, O>>,
83
+ ): EvalReport<I, O> {
84
+ const total = results.length;
85
+ let passed = 0;
86
+ let failed = 0;
87
+ let durationSum = 0;
88
+ const byScorer: Record<
89
+ string,
90
+ { passed: number; failed: number; scoreSum: number; scoreCount: number }
91
+ > = {};
92
+
93
+ for (const scorer of scorers) {
94
+ byScorer[scorer.name] = { passed: 0, failed: 0, scoreSum: 0, scoreCount: 0 };
95
+ }
96
+
97
+ for (const r of results) {
98
+ durationSum += r.durationMs;
99
+ let passEntire = true;
100
+ for (const { scorer, result } of r.scores) {
101
+ const bucket = byScorer[scorer] ?? { passed: 0, failed: 0, scoreSum: 0, scoreCount: 0 };
102
+ if (result.pass) bucket.passed += 1;
103
+ else {
104
+ bucket.failed += 1;
105
+ passEntire = false;
106
+ }
107
+ if (typeof result.score === 'number' && Number.isFinite(result.score)) {
108
+ bucket.scoreSum += result.score;
109
+ bucket.scoreCount += 1;
110
+ }
111
+ byScorer[scorer] = bucket;
112
+ }
113
+ if (passEntire) passed += 1;
114
+ else failed += 1;
115
+ }
116
+
117
+ const summaryByScorer: Record<
118
+ string,
119
+ { passed: number; failed: number; avgScore: number | null }
120
+ > = {};
121
+ for (const [scorer, b] of Object.entries(byScorer)) {
122
+ summaryByScorer[scorer] = {
123
+ passed: b.passed,
124
+ failed: b.failed,
125
+ avgScore: b.scoreCount === 0 ? null : b.scoreSum / b.scoreCount,
126
+ };
127
+ }
128
+
129
+ return {
130
+ results,
131
+ summary: {
132
+ total,
133
+ passed,
134
+ failed,
135
+ avgDurationMs: total === 0 ? 0 : durationSum / total,
136
+ byScorer: Object.freeze(summaryByScorer),
137
+ },
138
+ };
139
+ }
140
+
141
+ function throwIfAborted(signal: AbortSignal | undefined): void {
142
+ if (signal?.aborted === true) {
143
+ const reason = signal.reason;
144
+ throw reason instanceof Error ? reason : new Error('Eval run aborted');
145
+ }
146
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Public types for the minimal inline eval runner. Full orchestrator,
3
+ * scorer libraries, dataset loaders, and reporters live in the
4
+ * separate `@graphorin/evals` package (post-MVP).
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ /**
10
+ * One sample from an eval dataset.
11
+ *
12
+ * @stable
13
+ */
14
+ export interface Case<I, O = unknown, M = Readonly<Record<string, unknown>>> {
15
+ readonly id?: string;
16
+ readonly input: I;
17
+ readonly expected?: O;
18
+ readonly metadata?: M;
19
+ }
20
+
21
+ /**
22
+ * @stable
23
+ */
24
+ export interface Dataset<I, O = unknown, M = Readonly<Record<string, unknown>>> {
25
+ readonly cases: ReadonlyArray<Case<I, O, M>>;
26
+ readonly metadata?: {
27
+ readonly name?: string;
28
+ readonly description?: string;
29
+ readonly createdAt?: Date;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Output of {@link Scorer.score}.
35
+ *
36
+ * @stable
37
+ */
38
+ export interface ScoreResult {
39
+ readonly pass: boolean;
40
+ /** Optional normalized score in `[0, 1]`. */
41
+ readonly score?: number;
42
+ readonly reason?: string;
43
+ readonly metadata?: Readonly<Record<string, unknown>>;
44
+ }
45
+
46
+ /**
47
+ * @stable
48
+ */
49
+ export interface Scorer<I, O = unknown> {
50
+ readonly name: string;
51
+ score(args: {
52
+ readonly case: Case<I, O>;
53
+ readonly output: O;
54
+ readonly durationMs: number;
55
+ }): Promise<ScoreResult>;
56
+ }
57
+
58
+ /**
59
+ * Per-case result.
60
+ *
61
+ * @stable
62
+ */
63
+ export interface EvalCaseResult<I, O> {
64
+ readonly caseId: string;
65
+ readonly input: I;
66
+ readonly output: O;
67
+ readonly durationMs: number;
68
+ readonly scores: ReadonlyArray<{ readonly scorer: string; readonly result: ScoreResult }>;
69
+ }
70
+
71
+ /**
72
+ * Final report shape.
73
+ *
74
+ * @stable
75
+ */
76
+ export interface EvalReport<I, O> {
77
+ readonly results: ReadonlyArray<EvalCaseResult<I, O>>;
78
+ readonly summary: {
79
+ readonly total: number;
80
+ readonly passed: number;
81
+ readonly failed: number;
82
+ readonly avgDurationMs: number;
83
+ readonly byScorer: Readonly<
84
+ Record<
85
+ string,
86
+ { readonly passed: number; readonly failed: number; readonly avgScore: number | null }
87
+ >
88
+ >;
89
+ /**
90
+ * 95% Wilson score interval on the overall pass rate (E8 / evals-05).
91
+ * Always present on reports produced by `runEvals`; optional so older
92
+ * persisted reports keep parsing.
93
+ */
94
+ readonly passRateCi?: { readonly lo: number; readonly hi: number };
95
+ /**
96
+ * pass^k stability metric - fraction of base cases whose EVERY repeat
97
+ * iteration passed. Present only when the run used `iterations > 1`.
98
+ */
99
+ readonly passHatK?: {
100
+ readonly k: number;
101
+ readonly baseCases: number;
102
+ readonly value: number;
103
+ };
104
+ };
105
+ /**
106
+ * `true` when the run was cut short by an aborted signal - `results` and
107
+ * `summary` then cover only the cases that finished before the abort (a
108
+ * partial report). Absent on a normal full run. See `runEvals`.
109
+ */
110
+ readonly aborted?: boolean;
111
+ }
112
+
113
+ /**
114
+ * @stable
115
+ */
116
+ export interface RunEvalOptions<I, O> {
117
+ readonly agent: { readonly run: (input: I) => Promise<O> };
118
+ readonly dataset: Dataset<I, O>;
119
+ readonly scorers: ReadonlyArray<Scorer<I, O>>;
120
+ readonly iterations?: number;
121
+ readonly signal?: AbortSignal;
122
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `ConsoleExporter` - pretty-prints finished spans to `console.log`.
3
+ *
4
+ * Useful for development and unit testing. Production deployments
5
+ * should use {@link JSONLExporter} (for replay) or
6
+ * {@link OTLPHttpExporter} (for remote OTLP collectors).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type { SpanRecord, TraceExporter } from './types.js';
12
+
13
+ /**
14
+ * Configuration shape for {@link createConsoleExporter}.
15
+ *
16
+ * @stable
17
+ */
18
+ export interface ConsoleExporterOptions {
19
+ /** Identifier reported via `exporter.id`. Defaults to `'console'`. */
20
+ readonly id?: string;
21
+ /** When `true`, emit JSON pretty-printed across multiple lines. */
22
+ readonly pretty?: boolean;
23
+ /** Custom sink. Defaults to `console.log`. */
24
+ readonly sink?: (line: string) => void;
25
+ }
26
+
27
+ /**
28
+ * Build a console-based trace exporter. Call `withValidation(exporter)`
29
+ * before passing the result to `createTracer({ exporters })`.
30
+ *
31
+ * @stable
32
+ */
33
+ export function createConsoleExporter(opts: ConsoleExporterOptions = {}): TraceExporter {
34
+ const id = opts.id ?? 'console';
35
+ const pretty = opts.pretty === true;
36
+ const sink = opts.sink ?? ((line: string) => console.log(line));
37
+
38
+ let closed = false;
39
+ return {
40
+ id,
41
+ async export(record: SpanRecord): Promise<void> {
42
+ if (closed) return;
43
+ const payload = pretty
44
+ ? JSON.stringify(serializableRecord(record), null, 2)
45
+ : JSON.stringify(serializableRecord(record));
46
+ sink(payload);
47
+ },
48
+ async flush(): Promise<void> {
49
+ // Console.log is synchronous on Node; nothing to flush.
50
+ },
51
+ async shutdown(): Promise<void> {
52
+ closed = true;
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @internal
59
+ */
60
+ export function serializableRecord(record: SpanRecord): Record<string, unknown> {
61
+ return {
62
+ type: record.type,
63
+ id: record.id,
64
+ traceId: record.traceId,
65
+ ...(record.parentId === undefined ? {} : { parentId: record.parentId }),
66
+ name: record.name,
67
+ startUnixNano: record.startUnixNano,
68
+ endUnixNano: record.endUnixNano,
69
+ status: record.status,
70
+ ...(record.statusMessage === undefined ? {} : { statusMessage: record.statusMessage }),
71
+ attributes: record.attributes,
72
+ events: record.events,
73
+ ...(record.droppedReason === undefined ? {} : { droppedReason: record.droppedReason }),
74
+ };
75
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Exporter surface for `@graphorin/observability`.
3
+ *
4
+ * Every exporter MUST be wrapped via {@link withValidation} before it
5
+ * is registered with the tracer. Un-wrapped exporters cause
6
+ * `createTracer(...)` to throw at startup unless the operator opts
7
+ * out explicitly with `validation: 'off'` (in which case a startup
8
+ * WARN is logged).
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ export {
14
+ type ConsoleExporterOptions,
15
+ createConsoleExporter,
16
+ } from './console.js';
17
+ export {
18
+ createJSONLExporter,
19
+ type JSONLExporterOptions,
20
+ } from './jsonl.js';
21
+ export {
22
+ createOTLPHttpExporter,
23
+ type OTLPHttpExporterOptions,
24
+ } from './otlp-http.js';
25
+ export type {
26
+ SpanRecord,
27
+ SpanRecordEvent,
28
+ TraceExporter,
29
+ } from './types.js';
30
+ export { VALIDATED_EXPORTER_BRAND } from './types.js';
31
+ export {
32
+ isValidatedExporter,
33
+ tryGetValidatorCounters,
34
+ type WithValidationOptions,
35
+ withValidation,
36
+ } from './with-validation.js';
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `JSONLExporter` - append-only newline-delimited JSON output. Used as
3
+ * the default trace replay log; encryption-at-rest is opt-in via the
4
+ * encryption hook (Phase 16).
5
+ *
6
+ * The exporter:
7
+ * - creates the target directory with mode `0700`,
8
+ * - opens the per-session file with mode `0600`,
9
+ * - rotates per UTC month + per session id (`YYYY-MM/<sessionId>.jsonl`),
10
+ * - flushes every write (no internal buffer) so the file is suitable
11
+ * for tail-style replay even after a crash.
12
+ *
13
+ * The output is deterministic - see {@link serializableRecord}.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { mkdir, open } from 'node:fs/promises';
19
+ import { dirname, join } from 'node:path';
20
+ import { serializableRecord } from './console.js';
21
+ import type { SpanRecord, TraceExporter } from './types.js';
22
+
23
+ /** @internal - used by tests to override the date provider. */
24
+ export type DateProvider = () => Date;
25
+
26
+ /**
27
+ * Configuration shape for {@link createJSONLExporter}.
28
+ *
29
+ * @stable
30
+ */
31
+ export interface JSONLExporterOptions {
32
+ /** Identifier reported via `exporter.id`. Defaults to `'jsonl'`. */
33
+ readonly id?: string;
34
+ /** Root directory for the trace files. Created if missing. */
35
+ readonly path: string;
36
+ /**
37
+ * Resolver that picks the file path for a given span record. Defaults
38
+ * to `<rootPath>/<YYYY-MM>/<sessionId>.jsonl` keyed off the
39
+ * `graphorin.session.id` attribute (or `unsessioned.jsonl` when
40
+ * absent).
41
+ */
42
+ readonly resolveFilePath?: (record: SpanRecord, root: string) => string;
43
+ /**
44
+ * Override the date used when picking the rotation directory. Defaults
45
+ * to `() => new Date()`.
46
+ *
47
+ * @internal
48
+ */
49
+ readonly now?: DateProvider;
50
+ /**
51
+ * Upper bound on simultaneously-open file handles (RP-20). The pool keys
52
+ * by `(session, UTC-month)`, so a long-living server would otherwise hold
53
+ * one fd per pair forever; the least-recently-used handle is closed once
54
+ * the cap is reached. Defaults to `64`.
55
+ */
56
+ readonly maxOpenHandles?: number;
57
+ }
58
+
59
+ /**
60
+ * A JSONL exporter, plus an introspection seam for the bounded handle pool.
61
+ *
62
+ * @stable
63
+ */
64
+ export interface JSONLExporter extends TraceExporter {
65
+ /** Number of file handles currently open in the pool (RP-20). */
66
+ openHandleCount(): number;
67
+ }
68
+
69
+ /**
70
+ * Build a JSONL trace exporter. Call `withValidation(exporter)` before
71
+ * passing the result to `createTracer({ exporters })`.
72
+ *
73
+ * @stable
74
+ */
75
+ export function createJSONLExporter(opts: JSONLExporterOptions): JSONLExporter {
76
+ const id = opts.id ?? 'jsonl';
77
+ const root = opts.path;
78
+ const now = opts.now ?? (() => new Date());
79
+ const resolveFilePath = opts.resolveFilePath ?? defaultPathResolver(now);
80
+ const maxOpenHandles = Math.max(1, opts.maxOpenHandles ?? 64);
81
+
82
+ // Pool of file handles, keyed by absolute path. Each handle is opened
83
+ // in append mode the first time we touch the path; subsequent exports
84
+ // re-use the existing handle. The pool is LRU-bounded (RP-20) - the Map's
85
+ // insertion order is the recency order, so the first entry is the LRU.
86
+ const handles = new Map<string, FileHandleEntry>();
87
+ let closed = false;
88
+
89
+ return {
90
+ id,
91
+ async export(record: SpanRecord): Promise<void> {
92
+ if (closed) return;
93
+ const filePath = resolveFilePath(record, root);
94
+ const entry = await openOrReuse(handles, filePath, maxOpenHandles);
95
+ const line = `${JSON.stringify(serializableRecord(record))}\n`;
96
+ await entry.handle.write(line, null, 'utf8');
97
+ },
98
+ async flush(): Promise<void> {
99
+ for (const entry of handles.values()) {
100
+ await entry.handle.datasync();
101
+ }
102
+ },
103
+ async shutdown(): Promise<void> {
104
+ closed = true;
105
+ for (const entry of handles.values()) {
106
+ await entry.handle.close();
107
+ }
108
+ handles.clear();
109
+ },
110
+ openHandleCount(): number {
111
+ return handles.size;
112
+ },
113
+ };
114
+ }
115
+
116
+ interface FileHandleEntry {
117
+ readonly handle: import('node:fs/promises').FileHandle;
118
+ }
119
+
120
+ async function openOrReuse(
121
+ pool: Map<string, FileHandleEntry>,
122
+ filePath: string,
123
+ maxOpenHandles: number,
124
+ ): Promise<FileHandleEntry> {
125
+ const cached = pool.get(filePath);
126
+ if (cached !== undefined) {
127
+ // Mark most-recently-used by re-inserting at the tail of the Map.
128
+ pool.delete(filePath);
129
+ pool.set(filePath, cached);
130
+ return cached;
131
+ }
132
+
133
+ // RP-20: evict least-recently-used handles before exceeding the cap so the
134
+ // open-fd count stays bounded. An evicted path simply re-opens (append mode)
135
+ // on its next export - no data is lost.
136
+ while (pool.size >= maxOpenHandles) {
137
+ const lruKey = pool.keys().next().value;
138
+ if (lruKey === undefined) break;
139
+ const lru = pool.get(lruKey);
140
+ pool.delete(lruKey);
141
+ if (lru !== undefined) {
142
+ try {
143
+ await lru.handle.close();
144
+ } catch {
145
+ // Best-effort - a failed close must not block the new open.
146
+ }
147
+ }
148
+ }
149
+
150
+ await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
151
+ const handle = await open(filePath, 'a', 0o600);
152
+ const entry: FileHandleEntry = { handle };
153
+ pool.set(filePath, entry);
154
+ return entry;
155
+ }
156
+
157
+ function defaultPathResolver(now: DateProvider) {
158
+ return (record: SpanRecord, root: string): string => {
159
+ const sessionAttr = record.attributes['graphorin.session.id'];
160
+ const sessionId = typeof sessionAttr === 'string' ? sessionAttr : 'unsessioned';
161
+ const date = now();
162
+ const yyyy = String(date.getUTCFullYear());
163
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
164
+ const safeSession = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
165
+ return join(root, `${yyyy}-${mm}`, `${safeSession}.jsonl`);
166
+ };
167
+ }