@seanhogg/builderforce-memory 2026.7.12 → 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.
Files changed (223) hide show
  1. package/README.md +10 -0
  2. package/dist/bridges/AnthropicBridge.d.ts +9 -1
  3. package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
  4. package/dist/bridges/AnthropicBridge.js +58 -2
  5. package/dist/bridges/AnthropicBridge.js.map +1 -1
  6. package/dist/bridges/CachingBridge.d.ts +9 -1
  7. package/dist/bridges/CachingBridge.d.ts.map +1 -1
  8. package/dist/bridges/CachingBridge.js +24 -1
  9. package/dist/bridges/CachingBridge.js.map +1 -1
  10. package/dist/bridges/OpenAIBridge.d.ts +4 -1
  11. package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
  12. package/dist/bridges/OpenAIBridge.js +40 -2
  13. package/dist/bridges/OpenAIBridge.js.map +1 -1
  14. package/dist/bridges/SemanticCachingBridge.d.ts +8 -1
  15. package/dist/bridges/SemanticCachingBridge.d.ts.map +1 -1
  16. package/dist/bridges/SemanticCachingBridge.js +26 -1
  17. package/dist/bridges/SemanticCachingBridge.js.map +1 -1
  18. package/dist/bridges/TransformerBridge.d.ts +13 -0
  19. package/dist/bridges/TransformerBridge.d.ts.map +1 -1
  20. package/dist/bridges/VertexAIBridge.d.ts +102 -0
  21. package/dist/bridges/VertexAIBridge.d.ts.map +1 -0
  22. package/dist/bridges/VertexAIBridge.js +224 -0
  23. package/dist/bridges/VertexAIBridge.js.map +1 -0
  24. package/dist/bridges/index.d.ts +3 -1
  25. package/dist/bridges/index.d.ts.map +1 -1
  26. package/dist/bridges/index.js +1 -0
  27. package/dist/bridges/index.js.map +1 -1
  28. package/dist/eval/EvalHarness.d.ts +37 -0
  29. package/dist/eval/EvalHarness.d.ts.map +1 -0
  30. package/dist/eval/EvalHarness.js +178 -0
  31. package/dist/eval/EvalHarness.js.map +1 -0
  32. package/dist/eval/graders.d.ts +76 -0
  33. package/dist/eval/graders.d.ts.map +1 -0
  34. package/dist/eval/graders.js +168 -0
  35. package/dist/eval/graders.js.map +1 -0
  36. package/dist/eval/index.d.ts +26 -0
  37. package/dist/eval/index.d.ts.map +1 -0
  38. package/dist/eval/index.js +26 -0
  39. package/dist/eval/index.js.map +1 -0
  40. package/dist/eval/types.d.ts +97 -0
  41. package/dist/eval/types.d.ts.map +1 -0
  42. package/dist/eval/types.js +5 -0
  43. package/dist/eval/types.js.map +1 -0
  44. package/dist/evermind/contract.d.ts +40 -0
  45. package/dist/evermind/contract.d.ts.map +1 -0
  46. package/dist/evermind/contract.js +13 -0
  47. package/dist/evermind/contract.js.map +1 -0
  48. package/dist/evermind/index.d.ts +15 -0
  49. package/dist/evermind/index.d.ts.map +1 -0
  50. package/dist/evermind/index.js +4 -0
  51. package/dist/evermind/index.js.map +1 -0
  52. package/dist/evermind/memoryBlock.d.ts +7 -0
  53. package/dist/evermind/memoryBlock.d.ts.map +1 -0
  54. package/dist/evermind/memoryBlock.js +37 -0
  55. package/dist/evermind/memoryBlock.js.map +1 -0
  56. package/dist/evermind/recall.d.ts +55 -0
  57. package/dist/evermind/recall.d.ts.map +1 -0
  58. package/dist/evermind/recall.js +105 -0
  59. package/dist/evermind/recall.js.map +1 -0
  60. package/dist/index.d.ts +18 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +14 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/ingest/IngestionPipeline.d.ts +95 -0
  65. package/dist/ingest/IngestionPipeline.d.ts.map +1 -0
  66. package/dist/ingest/IngestionPipeline.js +295 -0
  67. package/dist/ingest/IngestionPipeline.js.map +1 -0
  68. package/dist/ingest/index.d.ts +10 -0
  69. package/dist/ingest/index.d.ts.map +1 -0
  70. package/dist/ingest/index.js +8 -0
  71. package/dist/ingest/index.js.map +1 -0
  72. package/dist/ingest/parsers.d.ts +53 -0
  73. package/dist/ingest/parsers.d.ts.map +1 -0
  74. package/dist/ingest/parsers.js +306 -0
  75. package/dist/ingest/parsers.js.map +1 -0
  76. package/dist/ingest/types.d.ts +114 -0
  77. package/dist/ingest/types.d.ts.map +1 -0
  78. package/dist/ingest/types.js +19 -0
  79. package/dist/ingest/types.js.map +1 -0
  80. package/dist/memory/MemoryStore.d.ts +33 -0
  81. package/dist/memory/MemoryStore.d.ts.map +1 -1
  82. package/dist/memory/MemoryStore.js +29 -3
  83. package/dist/memory/MemoryStore.js.map +1 -1
  84. package/dist/memory/index.d.ts +1 -1
  85. package/dist/memory/index.d.ts.map +1 -1
  86. package/dist/orchestration/AgentGraph.d.ts +104 -0
  87. package/dist/orchestration/AgentGraph.d.ts.map +1 -0
  88. package/dist/orchestration/AgentGraph.js +364 -0
  89. package/dist/orchestration/AgentGraph.js.map +1 -0
  90. package/dist/orchestration/checkpoint.d.ts +58 -0
  91. package/dist/orchestration/checkpoint.d.ts.map +1 -0
  92. package/dist/orchestration/checkpoint.js +92 -0
  93. package/dist/orchestration/checkpoint.js.map +1 -0
  94. package/dist/orchestration/index.d.ts +14 -0
  95. package/dist/orchestration/index.d.ts.map +1 -0
  96. package/dist/orchestration/index.js +10 -0
  97. package/dist/orchestration/index.js.map +1 -0
  98. package/dist/orchestration/patterns.d.ts +159 -0
  99. package/dist/orchestration/patterns.d.ts.map +1 -0
  100. package/dist/orchestration/patterns.js +371 -0
  101. package/dist/orchestration/patterns.js.map +1 -0
  102. package/dist/orchestration/types.d.ts +146 -0
  103. package/dist/orchestration/types.d.ts.map +1 -0
  104. package/dist/orchestration/types.js +20 -0
  105. package/dist/orchestration/types.js.map +1 -0
  106. package/dist/rag/EnterpriseRetriever.d.ts +114 -0
  107. package/dist/rag/EnterpriseRetriever.d.ts.map +1 -0
  108. package/dist/rag/EnterpriseRetriever.js +240 -0
  109. package/dist/rag/EnterpriseRetriever.js.map +1 -0
  110. package/dist/rag/index.d.ts +6 -0
  111. package/dist/rag/index.d.ts.map +1 -0
  112. package/dist/rag/index.js +5 -0
  113. package/dist/rag/index.js.map +1 -0
  114. package/dist/retrieval/bm25.d.ts +48 -1
  115. package/dist/retrieval/bm25.d.ts.map +1 -1
  116. package/dist/retrieval/bm25.js +59 -13
  117. package/dist/retrieval/bm25.js.map +1 -1
  118. package/dist/retrieval/index.d.ts +2 -2
  119. package/dist/retrieval/index.d.ts.map +1 -1
  120. package/dist/retrieval/index.js +1 -1
  121. package/dist/retrieval/index.js.map +1 -1
  122. package/dist/telemetry/InstrumentedBridge.d.ts +62 -0
  123. package/dist/telemetry/InstrumentedBridge.d.ts.map +1 -0
  124. package/dist/telemetry/InstrumentedBridge.js +120 -0
  125. package/dist/telemetry/InstrumentedBridge.js.map +1 -0
  126. package/dist/telemetry/MetricsRegistry.d.ts +88 -0
  127. package/dist/telemetry/MetricsRegistry.d.ts.map +1 -0
  128. package/dist/telemetry/MetricsRegistry.js +167 -0
  129. package/dist/telemetry/MetricsRegistry.js.map +1 -0
  130. package/dist/telemetry/Tracer.d.ts +137 -0
  131. package/dist/telemetry/Tracer.d.ts.map +1 -0
  132. package/dist/telemetry/Tracer.js +244 -0
  133. package/dist/telemetry/Tracer.js.map +1 -0
  134. package/dist/telemetry/exporters.d.ts +68 -0
  135. package/dist/telemetry/exporters.d.ts.map +1 -0
  136. package/dist/telemetry/exporters.js +145 -0
  137. package/dist/telemetry/exporters.js.map +1 -0
  138. package/dist/telemetry/index.d.ts +19 -0
  139. package/dist/telemetry/index.d.ts.map +1 -0
  140. package/dist/telemetry/index.js +12 -0
  141. package/dist/telemetry/index.js.map +1 -0
  142. package/dist/telemetry/pricing.d.ts +74 -0
  143. package/dist/telemetry/pricing.d.ts.map +1 -0
  144. package/dist/telemetry/pricing.js +98 -0
  145. package/dist/telemetry/pricing.js.map +1 -0
  146. package/dist/telemetry/traceparent.d.ts +50 -0
  147. package/dist/telemetry/traceparent.d.ts.map +1 -0
  148. package/dist/telemetry/traceparent.js +77 -0
  149. package/dist/telemetry/traceparent.js.map +1 -0
  150. package/dist/telemetry/types.d.ts +90 -0
  151. package/dist/telemetry/types.d.ts.map +1 -0
  152. package/dist/telemetry/types.js +17 -0
  153. package/dist/telemetry/types.js.map +1 -0
  154. package/dist/vectorstore/MemoryVectorStore.d.ts +72 -0
  155. package/dist/vectorstore/MemoryVectorStore.d.ts.map +1 -0
  156. package/dist/vectorstore/MemoryVectorStore.js +205 -0
  157. package/dist/vectorstore/MemoryVectorStore.js.map +1 -0
  158. package/dist/vectorstore/RestVectorStore.d.ts +79 -0
  159. package/dist/vectorstore/RestVectorStore.d.ts.map +1 -0
  160. package/dist/vectorstore/RestVectorStore.js +170 -0
  161. package/dist/vectorstore/RestVectorStore.js.map +1 -0
  162. package/dist/vectorstore/dialects.d.ts +101 -0
  163. package/dist/vectorstore/dialects.d.ts.map +1 -0
  164. package/dist/vectorstore/dialects.js +618 -0
  165. package/dist/vectorstore/dialects.js.map +1 -0
  166. package/dist/vectorstore/filter.d.ts +42 -0
  167. package/dist/vectorstore/filter.d.ts.map +1 -0
  168. package/dist/vectorstore/filter.js +132 -0
  169. package/dist/vectorstore/filter.js.map +1 -0
  170. package/dist/vectorstore/index.d.ts +15 -0
  171. package/dist/vectorstore/index.d.ts.map +1 -0
  172. package/dist/vectorstore/index.js +11 -0
  173. package/dist/vectorstore/index.js.map +1 -0
  174. package/dist/vectorstore/types.d.ts +151 -0
  175. package/dist/vectorstore/types.d.ts.map +1 -0
  176. package/dist/vectorstore/types.js +30 -0
  177. package/dist/vectorstore/types.js.map +1 -0
  178. package/package.json +39 -5
  179. package/src/bridges/AnthropicBridge.ts +68 -3
  180. package/src/bridges/CachingBridge.ts +28 -2
  181. package/src/bridges/OpenAIBridge.ts +48 -3
  182. package/src/bridges/SemanticCachingBridge.ts +29 -2
  183. package/src/bridges/TransformerBridge.ts +16 -0
  184. package/src/bridges/VertexAIBridge.ts +297 -0
  185. package/src/bridges/index.ts +3 -1
  186. package/src/eval/EvalHarness.ts +214 -0
  187. package/src/eval/graders.ts +218 -0
  188. package/src/eval/index.ts +26 -0
  189. package/src/eval/types.ts +107 -0
  190. package/src/evermind/contract.ts +39 -0
  191. package/src/evermind/index.ts +21 -0
  192. package/src/evermind/memoryBlock.ts +38 -0
  193. package/src/evermind/recall.ts +122 -0
  194. package/src/index.ts +232 -0
  195. package/src/ingest/IngestionPipeline.ts +378 -0
  196. package/src/ingest/index.ts +46 -0
  197. package/src/ingest/parsers.ts +318 -0
  198. package/src/ingest/types.ts +116 -0
  199. package/src/memory/MemoryStore.ts +55 -3
  200. package/src/memory/index.ts +1 -1
  201. package/src/orchestration/AgentGraph.ts +462 -0
  202. package/src/orchestration/checkpoint.ts +129 -0
  203. package/src/orchestration/index.ts +63 -0
  204. package/src/orchestration/patterns.ts +532 -0
  205. package/src/orchestration/types.ts +128 -0
  206. package/src/rag/EnterpriseRetriever.ts +348 -0
  207. package/src/rag/index.ts +18 -0
  208. package/src/retrieval/bm25.ts +71 -13
  209. package/src/retrieval/index.ts +2 -2
  210. package/src/telemetry/InstrumentedBridge.ts +152 -0
  211. package/src/telemetry/MetricsRegistry.ts +263 -0
  212. package/src/telemetry/Tracer.ts +310 -0
  213. package/src/telemetry/exporters.ts +187 -0
  214. package/src/telemetry/index.ts +56 -0
  215. package/src/telemetry/pricing.ts +129 -0
  216. package/src/telemetry/traceparent.ts +89 -0
  217. package/src/telemetry/types.ts +105 -0
  218. package/src/vectorstore/MemoryVectorStore.ts +250 -0
  219. package/src/vectorstore/RestVectorStore.ts +236 -0
  220. package/src/vectorstore/dialects.ts +702 -0
  221. package/src/vectorstore/filter.ts +148 -0
  222. package/src/vectorstore/index.ts +53 -0
  223. 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
+ }