@seanhogg/builderforce-memory 2026.7.14 → 2026.9.27
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/README.md +10 -0
- package/dist/bridges/AnthropicBridge.d.ts +9 -1
- package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
- package/dist/bridges/AnthropicBridge.js +58 -2
- package/dist/bridges/AnthropicBridge.js.map +1 -1
- package/dist/bridges/CachingBridge.d.ts +9 -1
- package/dist/bridges/CachingBridge.d.ts.map +1 -1
- package/dist/bridges/CachingBridge.js +24 -1
- package/dist/bridges/CachingBridge.js.map +1 -1
- package/dist/bridges/OpenAIBridge.d.ts +4 -1
- package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
- package/dist/bridges/OpenAIBridge.js +40 -2
- package/dist/bridges/OpenAIBridge.js.map +1 -1
- package/dist/bridges/SemanticCachingBridge.d.ts +8 -1
- package/dist/bridges/SemanticCachingBridge.d.ts.map +1 -1
- package/dist/bridges/SemanticCachingBridge.js +26 -1
- package/dist/bridges/SemanticCachingBridge.js.map +1 -1
- package/dist/bridges/TransformerBridge.d.ts +13 -0
- package/dist/bridges/TransformerBridge.d.ts.map +1 -1
- package/dist/bridges/VertexAIBridge.d.ts +102 -0
- package/dist/bridges/VertexAIBridge.d.ts.map +1 -0
- package/dist/bridges/VertexAIBridge.js +224 -0
- package/dist/bridges/VertexAIBridge.js.map +1 -0
- package/dist/bridges/index.d.ts +3 -1
- package/dist/bridges/index.d.ts.map +1 -1
- package/dist/bridges/index.js +1 -0
- package/dist/bridges/index.js.map +1 -1
- package/dist/eval/EvalHarness.d.ts +37 -0
- package/dist/eval/EvalHarness.d.ts.map +1 -0
- package/dist/eval/EvalHarness.js +178 -0
- package/dist/eval/EvalHarness.js.map +1 -0
- package/dist/eval/graders.d.ts +76 -0
- package/dist/eval/graders.d.ts.map +1 -0
- package/dist/eval/graders.js +168 -0
- package/dist/eval/graders.js.map +1 -0
- package/dist/eval/index.d.ts +26 -0
- package/dist/eval/index.d.ts.map +1 -0
- package/dist/eval/index.js +26 -0
- package/dist/eval/index.js.map +1 -0
- package/dist/eval/types.d.ts +97 -0
- package/dist/eval/types.d.ts.map +1 -0
- package/dist/eval/types.js +5 -0
- package/dist/eval/types.js.map +1 -0
- package/dist/evermind/contract.d.ts +40 -0
- package/dist/evermind/contract.d.ts.map +1 -0
- package/dist/evermind/contract.js +13 -0
- package/dist/evermind/contract.js.map +1 -0
- package/dist/evermind/index.d.ts +15 -0
- package/dist/evermind/index.d.ts.map +1 -0
- package/dist/evermind/index.js +4 -0
- package/dist/evermind/index.js.map +1 -0
- package/dist/evermind/memoryBlock.d.ts +7 -0
- package/dist/evermind/memoryBlock.d.ts.map +1 -0
- package/dist/evermind/memoryBlock.js +37 -0
- package/dist/evermind/memoryBlock.js.map +1 -0
- package/dist/evermind/recall.d.ts +55 -0
- package/dist/evermind/recall.d.ts.map +1 -0
- package/dist/evermind/recall.js +105 -0
- package/dist/evermind/recall.js.map +1 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/ingest/IngestionPipeline.d.ts +95 -0
- package/dist/ingest/IngestionPipeline.d.ts.map +1 -0
- package/dist/ingest/IngestionPipeline.js +295 -0
- package/dist/ingest/IngestionPipeline.js.map +1 -0
- package/dist/ingest/index.d.ts +10 -0
- package/dist/ingest/index.d.ts.map +1 -0
- package/dist/ingest/index.js +8 -0
- package/dist/ingest/index.js.map +1 -0
- package/dist/ingest/parsers.d.ts +53 -0
- package/dist/ingest/parsers.d.ts.map +1 -0
- package/dist/ingest/parsers.js +306 -0
- package/dist/ingest/parsers.js.map +1 -0
- package/dist/ingest/types.d.ts +114 -0
- package/dist/ingest/types.d.ts.map +1 -0
- package/dist/ingest/types.js +19 -0
- package/dist/ingest/types.js.map +1 -0
- package/dist/memory/MemoryStore.d.ts +33 -0
- package/dist/memory/MemoryStore.d.ts.map +1 -1
- package/dist/memory/MemoryStore.js +29 -3
- package/dist/memory/MemoryStore.js.map +1 -1
- package/dist/memory/index.d.ts +1 -1
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/orchestration/AgentGraph.d.ts +104 -0
- package/dist/orchestration/AgentGraph.d.ts.map +1 -0
- package/dist/orchestration/AgentGraph.js +364 -0
- package/dist/orchestration/AgentGraph.js.map +1 -0
- package/dist/orchestration/checkpoint.d.ts +58 -0
- package/dist/orchestration/checkpoint.d.ts.map +1 -0
- package/dist/orchestration/checkpoint.js +92 -0
- package/dist/orchestration/checkpoint.js.map +1 -0
- package/dist/orchestration/index.d.ts +14 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +10 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/patterns.d.ts +159 -0
- package/dist/orchestration/patterns.d.ts.map +1 -0
- package/dist/orchestration/patterns.js +371 -0
- package/dist/orchestration/patterns.js.map +1 -0
- package/dist/orchestration/types.d.ts +146 -0
- package/dist/orchestration/types.d.ts.map +1 -0
- package/dist/orchestration/types.js +20 -0
- package/dist/orchestration/types.js.map +1 -0
- package/dist/rag/EnterpriseRetriever.d.ts +114 -0
- package/dist/rag/EnterpriseRetriever.d.ts.map +1 -0
- package/dist/rag/EnterpriseRetriever.js +240 -0
- package/dist/rag/EnterpriseRetriever.js.map +1 -0
- package/dist/rag/index.d.ts +6 -0
- package/dist/rag/index.d.ts.map +1 -0
- package/dist/rag/index.js +5 -0
- package/dist/rag/index.js.map +1 -0
- package/dist/retrieval/bm25.d.ts +48 -1
- package/dist/retrieval/bm25.d.ts.map +1 -1
- package/dist/retrieval/bm25.js +59 -13
- package/dist/retrieval/bm25.js.map +1 -1
- package/dist/retrieval/index.d.ts +2 -2
- package/dist/retrieval/index.d.ts.map +1 -1
- package/dist/retrieval/index.js +1 -1
- package/dist/retrieval/index.js.map +1 -1
- package/dist/telemetry/InstrumentedBridge.d.ts +62 -0
- package/dist/telemetry/InstrumentedBridge.d.ts.map +1 -0
- package/dist/telemetry/InstrumentedBridge.js +120 -0
- package/dist/telemetry/InstrumentedBridge.js.map +1 -0
- package/dist/telemetry/MetricsRegistry.d.ts +88 -0
- package/dist/telemetry/MetricsRegistry.d.ts.map +1 -0
- package/dist/telemetry/MetricsRegistry.js +167 -0
- package/dist/telemetry/MetricsRegistry.js.map +1 -0
- package/dist/telemetry/Tracer.d.ts +137 -0
- package/dist/telemetry/Tracer.d.ts.map +1 -0
- package/dist/telemetry/Tracer.js +244 -0
- package/dist/telemetry/Tracer.js.map +1 -0
- package/dist/telemetry/exporters.d.ts +68 -0
- package/dist/telemetry/exporters.d.ts.map +1 -0
- package/dist/telemetry/exporters.js +145 -0
- package/dist/telemetry/exporters.js.map +1 -0
- package/dist/telemetry/index.d.ts +19 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +12 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/pricing.d.ts +74 -0
- package/dist/telemetry/pricing.d.ts.map +1 -0
- package/dist/telemetry/pricing.js +98 -0
- package/dist/telemetry/pricing.js.map +1 -0
- package/dist/telemetry/traceparent.d.ts +50 -0
- package/dist/telemetry/traceparent.d.ts.map +1 -0
- package/dist/telemetry/traceparent.js +77 -0
- package/dist/telemetry/traceparent.js.map +1 -0
- package/dist/telemetry/types.d.ts +90 -0
- package/dist/telemetry/types.d.ts.map +1 -0
- package/dist/telemetry/types.js +17 -0
- package/dist/telemetry/types.js.map +1 -0
- package/dist/vectorstore/MemoryVectorStore.d.ts +72 -0
- package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -0
- package/dist/vectorstore/MemoryVectorStore.js +205 -0
- package/dist/vectorstore/MemoryVectorStore.js.map +1 -0
- package/dist/vectorstore/RestVectorStore.d.ts +79 -0
- package/dist/vectorstore/RestVectorStore.d.ts.map +1 -0
- package/dist/vectorstore/RestVectorStore.js +170 -0
- package/dist/vectorstore/RestVectorStore.js.map +1 -0
- package/dist/vectorstore/dialects.d.ts +101 -0
- package/dist/vectorstore/dialects.d.ts.map +1 -0
- package/dist/vectorstore/dialects.js +618 -0
- package/dist/vectorstore/dialects.js.map +1 -0
- package/dist/vectorstore/filter.d.ts +42 -0
- package/dist/vectorstore/filter.d.ts.map +1 -0
- package/dist/vectorstore/filter.js +132 -0
- package/dist/vectorstore/filter.js.map +1 -0
- package/dist/vectorstore/index.d.ts +15 -0
- package/dist/vectorstore/index.d.ts.map +1 -0
- package/dist/vectorstore/index.js +11 -0
- package/dist/vectorstore/index.js.map +1 -0
- package/dist/vectorstore/types.d.ts +151 -0
- package/dist/vectorstore/types.d.ts.map +1 -0
- package/dist/vectorstore/types.js +30 -0
- package/dist/vectorstore/types.js.map +1 -0
- package/package.json +39 -5
- package/src/bridges/AnthropicBridge.ts +68 -3
- package/src/bridges/CachingBridge.ts +28 -2
- package/src/bridges/OpenAIBridge.ts +48 -3
- package/src/bridges/SemanticCachingBridge.ts +29 -2
- package/src/bridges/TransformerBridge.ts +16 -0
- package/src/bridges/VertexAIBridge.ts +297 -0
- package/src/bridges/index.ts +3 -1
- package/src/eval/EvalHarness.ts +214 -0
- package/src/eval/graders.ts +218 -0
- package/src/eval/index.ts +26 -0
- package/src/eval/types.ts +107 -0
- package/src/evermind/contract.ts +39 -0
- package/src/evermind/index.ts +21 -0
- package/src/evermind/memoryBlock.ts +38 -0
- package/src/evermind/recall.ts +122 -0
- package/src/index.ts +232 -0
- package/src/ingest/IngestionPipeline.ts +378 -0
- package/src/ingest/index.ts +46 -0
- package/src/ingest/parsers.ts +318 -0
- package/src/ingest/types.ts +116 -0
- package/src/memory/MemoryStore.ts +55 -3
- package/src/memory/index.ts +1 -1
- package/src/orchestration/AgentGraph.ts +462 -0
- package/src/orchestration/checkpoint.ts +129 -0
- package/src/orchestration/index.ts +63 -0
- package/src/orchestration/patterns.ts +532 -0
- package/src/orchestration/types.ts +128 -0
- package/src/rag/EnterpriseRetriever.ts +348 -0
- package/src/rag/index.ts +18 -0
- package/src/retrieval/bm25.ts +71 -13
- package/src/retrieval/index.ts +2 -2
- package/src/telemetry/InstrumentedBridge.ts +152 -0
- package/src/telemetry/MetricsRegistry.ts +263 -0
- package/src/telemetry/Tracer.ts +310 -0
- package/src/telemetry/exporters.ts +187 -0
- package/src/telemetry/index.ts +56 -0
- package/src/telemetry/pricing.ts +129 -0
- package/src/telemetry/traceparent.ts +89 -0
- package/src/telemetry/types.ts +105 -0
- package/src/vectorstore/MemoryVectorStore.ts +250 -0
- package/src/vectorstore/RestVectorStore.ts +236 -0
- package/src/vectorstore/dialects.ts +702 -0
- package/src/vectorstore/filter.ts +148 -0
- package/src/vectorstore/index.ts +53 -0
- package/src/vectorstore/types.ts +149 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/MetricsRegistry.ts — LLM-native metrics, aggregated from spans.
|
|
3
|
+
*
|
|
4
|
+
* The registry is itself a {@link SpanExporter}. That is the DRY move: every
|
|
5
|
+
* subsystem already emits spans for tracing, so metrics are a *projection* of the
|
|
6
|
+
* trace stream rather than a second instrumentation path that can drift out of
|
|
7
|
+
* agreement with it. Attach it to a tracer and tokens/sec, cost-per-request, TTFT
|
|
8
|
+
* and cache-hit rate fall out of the traffic that is already being traced.
|
|
9
|
+
*
|
|
10
|
+
* Metrics that matter for an LLM system, and why the usual web ones do not:
|
|
11
|
+
* • tokens/sec — throughput the user actually feels, not requests/sec.
|
|
12
|
+
* • cost per request — the only unit that ties spend to a product decision.
|
|
13
|
+
* • TTFT — perceived latency for streamed answers; p95 latency
|
|
14
|
+
* hides it entirely.
|
|
15
|
+
* • cache-hit rate — the lever that moves cost, split local vs provider.
|
|
16
|
+
* • unpriced calls — honesty: a model missing from the rate card is counted,
|
|
17
|
+
* never billed at a silent zero.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SpanData, SpanExporter, SpanKind } from './types.js';
|
|
21
|
+
|
|
22
|
+
export interface LlmMetricSnapshot {
|
|
23
|
+
/** Model id, or `'*'` for the all-models roll-up. */
|
|
24
|
+
model: string;
|
|
25
|
+
calls: number;
|
|
26
|
+
errors: number;
|
|
27
|
+
errorRate: number;
|
|
28
|
+
|
|
29
|
+
inputTokens: number;
|
|
30
|
+
outputTokens: number;
|
|
31
|
+
cachedInputTokens: number;
|
|
32
|
+
cacheWriteTokens: number;
|
|
33
|
+
totalTokens: number;
|
|
34
|
+
|
|
35
|
+
/** Sum of priced calls, USD. Calls on unpriced models contribute 0 here. */
|
|
36
|
+
costUsd: number;
|
|
37
|
+
avgCostPerRequestUsd: number;
|
|
38
|
+
/** Calls whose model was absent from the price book — excluded from `costUsd`. */
|
|
39
|
+
unpricedCalls: number;
|
|
40
|
+
/** Calls whose token counts were estimated rather than provider-reported. */
|
|
41
|
+
estimatedCalls: number;
|
|
42
|
+
|
|
43
|
+
totalDurationMs: number;
|
|
44
|
+
avgLatencyMs: number;
|
|
45
|
+
p50LatencyMs: number;
|
|
46
|
+
p95LatencyMs: number;
|
|
47
|
+
|
|
48
|
+
/** Output tokens per second of wall-clock spent generating them. */
|
|
49
|
+
tokensPerSecond: number;
|
|
50
|
+
/** Mean time-to-first-token over streamed calls; 0 when none streamed. */
|
|
51
|
+
avgTimeToFirstTokenMs: number;
|
|
52
|
+
streamedCalls: number;
|
|
53
|
+
|
|
54
|
+
/** Share of calls served without reaching a provider (exact/semantic cache). */
|
|
55
|
+
localCacheHitRate: number;
|
|
56
|
+
/** Share of input tokens served from the provider's prompt cache. */
|
|
57
|
+
providerCacheHitRate: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface KindMetricSnapshot {
|
|
61
|
+
kind: SpanKind;
|
|
62
|
+
count: number;
|
|
63
|
+
errors: number;
|
|
64
|
+
totalDurationMs: number;
|
|
65
|
+
avgLatencyMs: number;
|
|
66
|
+
p95LatencyMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MetricsRegistryOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Max latency samples retained per series for percentiles. Default 2048 —
|
|
72
|
+
* bounded so a long-lived process cannot grow the registry without limit.
|
|
73
|
+
*/
|
|
74
|
+
maxSamples?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface LlmAccumulator {
|
|
78
|
+
calls: number;
|
|
79
|
+
errors: number;
|
|
80
|
+
inputTokens: number;
|
|
81
|
+
outputTokens: number;
|
|
82
|
+
cachedInputTokens: number;
|
|
83
|
+
cacheWriteTokens: number;
|
|
84
|
+
costUsd: number;
|
|
85
|
+
unpricedCalls: number;
|
|
86
|
+
estimatedCalls: number;
|
|
87
|
+
localCacheHits: number;
|
|
88
|
+
totalDurationMs: number;
|
|
89
|
+
ttftTotalMs: number;
|
|
90
|
+
streamedCalls: number;
|
|
91
|
+
latencies: number[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface KindAccumulator {
|
|
95
|
+
count: number;
|
|
96
|
+
errors: number;
|
|
97
|
+
totalDurationMs: number;
|
|
98
|
+
latencies: number[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class MetricsRegistry implements SpanExporter {
|
|
102
|
+
private readonly _maxSamples: number;
|
|
103
|
+
private readonly _byModel = new Map<string, LlmAccumulator>();
|
|
104
|
+
private readonly _byKind = new Map<SpanKind, KindAccumulator>();
|
|
105
|
+
|
|
106
|
+
constructor(opts: MetricsRegistryOptions = {}) {
|
|
107
|
+
this._maxSamples = Math.max(1, opts.maxSamples ?? 2048);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** {@link SpanExporter} — attach this registry directly to a {@link Tracer}. */
|
|
111
|
+
export(spans: readonly SpanData[]): void {
|
|
112
|
+
for (const span of spans) this.record(span);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Folds one finished span into the aggregates. */
|
|
116
|
+
record(span: SpanData): void {
|
|
117
|
+
const duration = span.durationMs ?? 0;
|
|
118
|
+
const failed = span.status === 'error';
|
|
119
|
+
|
|
120
|
+
const kindAcc = this._kind(span.kind);
|
|
121
|
+
kindAcc.count += 1;
|
|
122
|
+
if (failed) kindAcc.errors += 1;
|
|
123
|
+
kindAcc.totalDurationMs += duration;
|
|
124
|
+
push(kindAcc.latencies, duration, this._maxSamples);
|
|
125
|
+
|
|
126
|
+
if (span.kind !== 'llm' && span.kind !== 'embedding') return;
|
|
127
|
+
|
|
128
|
+
const model = span.usage?.model ?? String(span.attributes['llm.model'] ?? 'unknown');
|
|
129
|
+
for (const acc of [this._model(model), this._model('*')]) {
|
|
130
|
+
acc.calls += 1;
|
|
131
|
+
if (failed) acc.errors += 1;
|
|
132
|
+
acc.totalDurationMs += duration;
|
|
133
|
+
push(acc.latencies, duration, this._maxSamples);
|
|
134
|
+
|
|
135
|
+
if (span.timeToFirstTokenMs !== undefined) {
|
|
136
|
+
acc.streamedCalls += 1;
|
|
137
|
+
acc.ttftTotalMs += span.timeToFirstTokenMs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const usage = span.usage;
|
|
141
|
+
if (!usage) continue;
|
|
142
|
+
|
|
143
|
+
acc.inputTokens += usage.inputTokens;
|
|
144
|
+
acc.outputTokens += usage.outputTokens;
|
|
145
|
+
acc.cachedInputTokens += usage.cachedInputTokens ?? 0;
|
|
146
|
+
acc.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
|
|
147
|
+
if (usage.estimated) acc.estimatedCalls += 1;
|
|
148
|
+
if (usage.localCacheHit) acc.localCacheHits += 1;
|
|
149
|
+
|
|
150
|
+
if (span.costUsd === undefined) acc.unpricedCalls += 1;
|
|
151
|
+
else acc.costUsd += span.costUsd;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Snapshot for one model, or the `'*'` roll-up when omitted. */
|
|
156
|
+
llm(model = '*'): LlmMetricSnapshot {
|
|
157
|
+
return summarize(model, this._byModel.get(model) ?? emptyLlm());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Snapshots for every model seen, excluding the `'*'` roll-up. */
|
|
161
|
+
models(): LlmMetricSnapshot[] {
|
|
162
|
+
return [...this._byModel.entries()]
|
|
163
|
+
.filter(([model]) => model !== '*')
|
|
164
|
+
.map(([model, acc]) => summarize(model, acc))
|
|
165
|
+
.sort((a, b) => b.costUsd - a.costUsd || b.calls - a.calls);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Latency/error snapshots per span kind — retrieval, ingest, graph, tool. */
|
|
169
|
+
kinds(): KindMetricSnapshot[] {
|
|
170
|
+
return [...this._byKind.entries()]
|
|
171
|
+
.map(([kind, acc]) => ({
|
|
172
|
+
kind,
|
|
173
|
+
count: acc.count,
|
|
174
|
+
errors: acc.errors,
|
|
175
|
+
totalDurationMs: acc.totalDurationMs,
|
|
176
|
+
avgLatencyMs: acc.count ? acc.totalDurationMs / acc.count : 0,
|
|
177
|
+
p95LatencyMs: percentile(acc.latencies, 0.95),
|
|
178
|
+
}))
|
|
179
|
+
.sort((a, b) => b.count - a.count);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
reset(): void {
|
|
183
|
+
this._byModel.clear();
|
|
184
|
+
this._byKind.clear();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private _model(model: string): LlmAccumulator {
|
|
188
|
+
let acc = this._byModel.get(model);
|
|
189
|
+
if (!acc) {
|
|
190
|
+
acc = emptyLlm();
|
|
191
|
+
this._byModel.set(model, acc);
|
|
192
|
+
}
|
|
193
|
+
return acc;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private _kind(kind: SpanKind): KindAccumulator {
|
|
197
|
+
let acc = this._byKind.get(kind);
|
|
198
|
+
if (!acc) {
|
|
199
|
+
acc = { count: 0, errors: 0, totalDurationMs: 0, latencies: [] };
|
|
200
|
+
this._byKind.set(kind, acc);
|
|
201
|
+
}
|
|
202
|
+
return acc;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function emptyLlm(): LlmAccumulator {
|
|
207
|
+
return {
|
|
208
|
+
calls: 0, errors: 0,
|
|
209
|
+
inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cacheWriteTokens: 0,
|
|
210
|
+
costUsd: 0, unpricedCalls: 0, estimatedCalls: 0, localCacheHits: 0,
|
|
211
|
+
totalDurationMs: 0, ttftTotalMs: 0, streamedCalls: 0, latencies: [],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summarize(model: string, acc: LlmAccumulator): LlmMetricSnapshot {
|
|
216
|
+
const totalTokens = acc.inputTokens + acc.outputTokens + acc.cachedInputTokens + acc.cacheWriteTokens;
|
|
217
|
+
const seconds = acc.totalDurationMs / 1000;
|
|
218
|
+
const billableInput = acc.inputTokens + acc.cachedInputTokens;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
model,
|
|
222
|
+
calls: acc.calls,
|
|
223
|
+
errors: acc.errors,
|
|
224
|
+
errorRate: acc.calls ? acc.errors / acc.calls : 0,
|
|
225
|
+
|
|
226
|
+
inputTokens: acc.inputTokens,
|
|
227
|
+
outputTokens: acc.outputTokens,
|
|
228
|
+
cachedInputTokens: acc.cachedInputTokens,
|
|
229
|
+
cacheWriteTokens: acc.cacheWriteTokens,
|
|
230
|
+
totalTokens,
|
|
231
|
+
|
|
232
|
+
costUsd: acc.costUsd,
|
|
233
|
+
avgCostPerRequestUsd: acc.calls ? acc.costUsd / acc.calls : 0,
|
|
234
|
+
unpricedCalls: acc.unpricedCalls,
|
|
235
|
+
estimatedCalls: acc.estimatedCalls,
|
|
236
|
+
|
|
237
|
+
totalDurationMs: acc.totalDurationMs,
|
|
238
|
+
avgLatencyMs: acc.calls ? acc.totalDurationMs / acc.calls : 0,
|
|
239
|
+
p50LatencyMs: percentile(acc.latencies, 0.5),
|
|
240
|
+
p95LatencyMs: percentile(acc.latencies, 0.95),
|
|
241
|
+
|
|
242
|
+
tokensPerSecond: seconds > 0 ? acc.outputTokens / seconds : 0,
|
|
243
|
+
avgTimeToFirstTokenMs: acc.streamedCalls ? acc.ttftTotalMs / acc.streamedCalls : 0,
|
|
244
|
+
streamedCalls: acc.streamedCalls,
|
|
245
|
+
|
|
246
|
+
localCacheHitRate: acc.calls ? acc.localCacheHits / acc.calls : 0,
|
|
247
|
+
providerCacheHitRate: billableInput ? acc.cachedInputTokens / billableInput : 0,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function push(samples: number[], value: number, cap: number): void {
|
|
252
|
+
samples.push(value);
|
|
253
|
+
if (samples.length > cap) samples.shift();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Nearest-rank percentile over an unsorted sample array. */
|
|
257
|
+
export function percentile(samples: readonly number[], q: number): number {
|
|
258
|
+
if (samples.length === 0) return 0;
|
|
259
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
260
|
+
const rank = Math.ceil(q * sorted.length);
|
|
261
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
|
262
|
+
return sorted[idx] as number;
|
|
263
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/Tracer.ts — granular tracing for agent runs.
|
|
3
|
+
*
|
|
4
|
+
* A production agent failure is almost never "the LLM was wrong"; it is "the
|
|
5
|
+
* retrieval returned nothing, so the LLM was asked to answer from an empty
|
|
6
|
+
* context". You can only see that with a span tree covering the WHOLE request —
|
|
7
|
+
* ingest, retrieval, each graph node, each model call — under one trace id.
|
|
8
|
+
*
|
|
9
|
+
* `now` and `newId` are injected so traces are deterministic under test; the
|
|
10
|
+
* defaults use wall-clock and a random hex id.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { estimateCostUsd, DEFAULT_PRICE_BOOK, type PriceBook } from './pricing.js';
|
|
14
|
+
import { formatTraceparent, type TraceContext } from './traceparent.js';
|
|
15
|
+
import type {
|
|
16
|
+
AttributeValue,
|
|
17
|
+
LlmUsage,
|
|
18
|
+
SpanData,
|
|
19
|
+
SpanEvent,
|
|
20
|
+
SpanExporter,
|
|
21
|
+
SpanKind,
|
|
22
|
+
SpanStatus,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
export interface SpanOptions {
|
|
26
|
+
kind?: SpanKind;
|
|
27
|
+
attributes?: Record<string, AttributeValue>;
|
|
28
|
+
/** Parent span. Omit for a root span (starts a new trace). */
|
|
29
|
+
parent?: Span;
|
|
30
|
+
/** Force a trace id (e.g. to continue a trace propagated from an HTTP header). */
|
|
31
|
+
traceId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* A parent span in ANOTHER process, decoded from a `traceparent` header. Adopts
|
|
34
|
+
* its trace id, links to it as parent, and — crucially — obeys its sampling
|
|
35
|
+
* flag, so the four hops of a cloud run are one tree or none, never a tree with
|
|
36
|
+
* a hole where a hop re-rolled the dice. Ignored when `parent` is given (an
|
|
37
|
+
* in-process parent is always the more specific answer).
|
|
38
|
+
*/
|
|
39
|
+
remoteParent?: TraceContext;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TracerOptions {
|
|
43
|
+
/** Where finished spans go. Omit to buffer only (readable via `finished()`). */
|
|
44
|
+
exporter?: SpanExporter;
|
|
45
|
+
/** Rates used to price `llm` spans. Default {@link DEFAULT_PRICE_BOOK}. */
|
|
46
|
+
pricing?: PriceBook;
|
|
47
|
+
/** Clock, injected for determinism. Default `Date.now`. */
|
|
48
|
+
now?: () => number;
|
|
49
|
+
/** Id factory, injected for determinism. Default random hex. */
|
|
50
|
+
newId?: (bytes: number) => string;
|
|
51
|
+
/**
|
|
52
|
+
* Export once this many spans have finished. Default 64. Set 1 to export
|
|
53
|
+
* eagerly, when a downstream collector wants a live stream.
|
|
54
|
+
*/
|
|
55
|
+
maxBatchSize?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Fraction of ROOT traces recorded, 0..1. Default 1. Sampling is decided once
|
|
58
|
+
* per trace and inherited by children, so a sampled trace is never a tree with
|
|
59
|
+
* holes in it.
|
|
60
|
+
*/
|
|
61
|
+
sampleRate?: number;
|
|
62
|
+
/** Sampling dice, injected for test. Default `Math.random`. */
|
|
63
|
+
random?: () => number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A live span. Mutating methods are no-ops once `end()`/`fail()` has run, so a
|
|
68
|
+
* late callback (a stream resolving after a timeout) cannot corrupt a span that
|
|
69
|
+
* has already been exported.
|
|
70
|
+
*/
|
|
71
|
+
export class Span {
|
|
72
|
+
private readonly _tracer: Tracer;
|
|
73
|
+
private readonly _data: SpanData;
|
|
74
|
+
private _closed = false;
|
|
75
|
+
private readonly _recorded: boolean;
|
|
76
|
+
|
|
77
|
+
/** @internal — construct via {@link Tracer.startSpan}. */
|
|
78
|
+
constructor(tracer: Tracer, data: SpanData, recorded: boolean) {
|
|
79
|
+
this._tracer = tracer;
|
|
80
|
+
this._data = data;
|
|
81
|
+
this._recorded = recorded;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get traceId(): string { return this._data.traceId; }
|
|
85
|
+
get spanId(): string { return this._data.spanId; }
|
|
86
|
+
get name(): string { return this._data.name; }
|
|
87
|
+
get closed(): boolean { return this._closed; }
|
|
88
|
+
/** False when this trace was dropped by sampling — nothing will be exported. */
|
|
89
|
+
get recorded(): boolean { return this._recorded; }
|
|
90
|
+
|
|
91
|
+
/** A snapshot copy; mutating it does not affect the span. */
|
|
92
|
+
get data(): SpanData {
|
|
93
|
+
return { ...this._data, attributes: { ...this._data.attributes }, events: [...this._data.events] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setAttribute(key: string, value: AttributeValue): this {
|
|
97
|
+
if (!this._closed) this._data.attributes[key] = value;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setAttributes(attrs: Record<string, AttributeValue | undefined>): this {
|
|
102
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
103
|
+
if (v !== undefined) this.setAttribute(k, v);
|
|
104
|
+
}
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
addEvent(name: string, attributes?: Record<string, AttributeValue>): this {
|
|
109
|
+
if (this._closed) return this;
|
|
110
|
+
const event: SpanEvent = { name, at: this._tracer.now() };
|
|
111
|
+
if (attributes) event.attributes = attributes;
|
|
112
|
+
this._data.events.push(event);
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Records token usage and prices it with the tracer's price book. */
|
|
117
|
+
recordUsage(usage: LlmUsage): this {
|
|
118
|
+
if (this._closed) return this;
|
|
119
|
+
this._data.usage = usage;
|
|
120
|
+
const cost = estimateCostUsd(usage, this._tracer.pricing);
|
|
121
|
+
if (cost !== undefined) this._data.costUsd = cost;
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Marks time-to-first-token; call when a stream yields its first chunk. */
|
|
126
|
+
recordFirstToken(): this {
|
|
127
|
+
if (this._closed) return this;
|
|
128
|
+
this._data.timeToFirstTokenMs = this._tracer.now() - this._data.startedAt;
|
|
129
|
+
return this.addEvent('first-token');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Opens a child span inside this one (same trace, this span as parent). */
|
|
133
|
+
child(name: string, opts: Omit<SpanOptions, 'parent' | 'traceId' | 'remoteParent'> = {}): Span {
|
|
134
|
+
return this._tracer.startSpan(name, { ...opts, parent: this });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* This span's position, for handing to another process. The sampling flag is the
|
|
139
|
+
* span's OWN `recorded` verdict, so the next hop inherits the decision already
|
|
140
|
+
* taken rather than making a second, possibly contradictory one.
|
|
141
|
+
*/
|
|
142
|
+
context(): TraceContext {
|
|
143
|
+
return { traceId: this._data.traceId, spanId: this._data.spanId, sampled: this._recorded };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The `traceparent` header value to send on an outbound request made inside this
|
|
148
|
+
* span. Set it and the callee's root span becomes this span's child.
|
|
149
|
+
*/
|
|
150
|
+
traceparent(): string {
|
|
151
|
+
return formatTraceparent(this.context());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
end(status: SpanStatus = 'ok'): SpanData {
|
|
155
|
+
return this._close(status);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Ends the span as failed, recording the error message. */
|
|
159
|
+
fail(error: unknown): SpanData {
|
|
160
|
+
if (!this._closed) {
|
|
161
|
+
this._data.error = error instanceof Error ? error.message : String(error);
|
|
162
|
+
}
|
|
163
|
+
return this._close('error');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private _close(status: SpanStatus): SpanData {
|
|
167
|
+
if (this._closed) return this.data;
|
|
168
|
+
this._closed = true;
|
|
169
|
+
this._data.status = status;
|
|
170
|
+
this._data.endedAt = this._tracer.now();
|
|
171
|
+
this._data.durationMs = this._data.endedAt - this._data.startedAt;
|
|
172
|
+
this._tracer._finish(this._data, this._recorded);
|
|
173
|
+
return this.data;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class Tracer {
|
|
178
|
+
readonly pricing: PriceBook;
|
|
179
|
+
|
|
180
|
+
private readonly _exporter?: SpanExporter;
|
|
181
|
+
private readonly _now: () => number;
|
|
182
|
+
private readonly _newId: (bytes: number) => string;
|
|
183
|
+
private readonly _maxBatch: number;
|
|
184
|
+
private readonly _sampleRate: number;
|
|
185
|
+
private readonly _random: () => number;
|
|
186
|
+
|
|
187
|
+
private _buffer: SpanData[] = [];
|
|
188
|
+
private readonly _finished: SpanData[] = [];
|
|
189
|
+
/** traceId -> sampling decision, so children inherit the root's verdict. */
|
|
190
|
+
private readonly _sampled = new Map<string, boolean>();
|
|
191
|
+
|
|
192
|
+
constructor(opts: TracerOptions = {}) {
|
|
193
|
+
if (opts.exporter) this._exporter = opts.exporter;
|
|
194
|
+
this.pricing = opts.pricing ?? DEFAULT_PRICE_BOOK;
|
|
195
|
+
this._now = opts.now ?? (() => Date.now());
|
|
196
|
+
this._newId = opts.newId ?? defaultIdFactory();
|
|
197
|
+
this._maxBatch = Math.max(1, opts.maxBatchSize ?? 64);
|
|
198
|
+
this._sampleRate = clamp01(opts.sampleRate ?? 1);
|
|
199
|
+
this._random = opts.random ?? Math.random;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
now(): number { return this._now(); }
|
|
203
|
+
|
|
204
|
+
startSpan(name: string, opts: SpanOptions = {}): Span {
|
|
205
|
+
// An in-process parent beats a remote one: if both are present the remote
|
|
206
|
+
// header is stale context that the live parent already descends from.
|
|
207
|
+
const remote = opts.parent ? undefined : opts.remoteParent;
|
|
208
|
+
const traceId = opts.parent?.traceId ?? remote?.traceId ?? opts.traceId ?? this._newId(16);
|
|
209
|
+
const recorded = remote
|
|
210
|
+
? this._adoptSampling(traceId, remote.sampled)
|
|
211
|
+
: this._decideSampling(traceId, Boolean(opts.parent));
|
|
212
|
+
|
|
213
|
+
const data: SpanData = {
|
|
214
|
+
traceId,
|
|
215
|
+
spanId: this._newId(8),
|
|
216
|
+
name,
|
|
217
|
+
kind: opts.kind ?? 'internal',
|
|
218
|
+
startedAt: this._now(),
|
|
219
|
+
attributes: { ...(opts.attributes ?? {}) },
|
|
220
|
+
events: [],
|
|
221
|
+
status: 'unset',
|
|
222
|
+
};
|
|
223
|
+
if (opts.parent) data.parentSpanId = opts.parent.spanId;
|
|
224
|
+
else if (remote) data.parentSpanId = remote.spanId;
|
|
225
|
+
|
|
226
|
+
return new Span(this, data, recorded);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Runs `fn` inside a span, ending it `ok` on resolve and `error` on throw.
|
|
231
|
+
* Every subsystem uses this rather than hand-pairing start/end, so a thrown
|
|
232
|
+
* error can never leak an un-ended span into the trace tree.
|
|
233
|
+
*/
|
|
234
|
+
async withSpan<T>(name: string, opts: SpanOptions, fn: (span: Span) => Promise<T> | T): Promise<T> {
|
|
235
|
+
const span = this.startSpan(name, opts);
|
|
236
|
+
try {
|
|
237
|
+
const out = await fn(span);
|
|
238
|
+
span.end('ok');
|
|
239
|
+
return out;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
span.fail(err);
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** All spans finished since construction (recorded ones only). */
|
|
247
|
+
finished(): readonly SpanData[] { return this._finished; }
|
|
248
|
+
|
|
249
|
+
/** Spans of one trace, in completion order. */
|
|
250
|
+
trace(traceId: string): readonly SpanData[] {
|
|
251
|
+
return this._finished.filter((s) => s.traceId === traceId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Pushes any buffered spans to the exporter. */
|
|
255
|
+
async flush(): Promise<void> {
|
|
256
|
+
if (this._buffer.length === 0) return;
|
|
257
|
+
const batch = this._buffer;
|
|
258
|
+
this._buffer = [];
|
|
259
|
+
await this._exporter?.export(batch);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async shutdown(): Promise<void> {
|
|
263
|
+
await this.flush();
|
|
264
|
+
await this._exporter?.shutdown?.();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @internal */
|
|
268
|
+
_finish(data: SpanData, recorded: boolean): void {
|
|
269
|
+
if (!recorded) return;
|
|
270
|
+
this._finished.push(data);
|
|
271
|
+
this._buffer.push(data);
|
|
272
|
+
if (this._buffer.length >= this._maxBatch) void this.flush();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Take the upstream hop's sampling verdict verbatim and record it against the
|
|
277
|
+
* trace, so every later span in this process inherits it too. Head-based
|
|
278
|
+
* sampling only works if it is decided ONCE, at the head.
|
|
279
|
+
*/
|
|
280
|
+
private _adoptSampling(traceId: string, sampled: boolean): boolean {
|
|
281
|
+
const known = this._sampled.get(traceId);
|
|
282
|
+
if (known !== undefined) return known;
|
|
283
|
+
this._sampled.set(traceId, sampled);
|
|
284
|
+
return sampled;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private _decideSampling(traceId: string, hasParent: boolean): boolean {
|
|
288
|
+
const known = this._sampled.get(traceId);
|
|
289
|
+
if (known !== undefined) return known;
|
|
290
|
+
// A child whose parent's decision is unknown defaults to recorded rather
|
|
291
|
+
// than re-rolling the dice, which would tear a trace in half.
|
|
292
|
+
const decision = hasParent || this._sampleRate >= 1 || this._random() < this._sampleRate;
|
|
293
|
+
this._sampled.set(traceId, decision);
|
|
294
|
+
return decision;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function clamp01(n: number): number {
|
|
299
|
+
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function defaultIdFactory(): (bytes: number) => string {
|
|
303
|
+
return (bytes: number) => {
|
|
304
|
+
let out = '';
|
|
305
|
+
for (let i = 0; i < bytes; i++) {
|
|
306
|
+
out += Math.floor(Math.random() * 256).toString(16).padStart(2, '0');
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
};
|
|
310
|
+
}
|