@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,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
|
+
}
|