adaptive-memory-multi-model-router 2.8.0 → 2.9.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/dist/index.d.ts +2 -0
- package/dist/index.js +14 -1
- package/dist/observability/index.d.ts +4 -0
- package/dist/observability/index.js +32 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/metrics.d.ts +35 -0
- package/dist/observability/metrics.js +181 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/observability/middleware.d.ts +16 -0
- package/dist/observability/middleware.js +129 -0
- package/dist/observability/middleware.js.map +1 -0
- package/dist/observability/tracer.d.ts +57 -0
- package/dist/observability/tracer.js +192 -0
- package/dist/observability/tracer.js.map +1 -0
- package/dist/observability/types.d.ts +52 -0
- package/dist/observability/types.js +4 -0
- package/dist/observability/types.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +16 -0
- package/src/observability/index.ts +10 -0
- package/src/observability/metrics.ts +215 -0
- package/src/observability/middleware.ts +164 -0
- package/src/observability/tracer.ts +229 -0
- package/src/observability/types.ts +45 -0
- package/test/test_observability.ts +390 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { Span, RouteTrace, ObservabilityEvent } from './types';
|
|
3
|
+
|
|
4
|
+
// Langfuse integration interface
|
|
5
|
+
interface LangfuseConfig {
|
|
6
|
+
publicKey?: string;
|
|
7
|
+
secretKey?: string;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface LangfuseClient {
|
|
12
|
+
trace: (data: any) => Promise<void>;
|
|
13
|
+
flush: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function generateId(): string {
|
|
17
|
+
return Math.random().toString(36).substring(2, 15) +
|
|
18
|
+
Math.random().toString(36).substring(2, 15);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class Tracer extends EventEmitter {
|
|
22
|
+
private traces: Map<string, Span> = new Map();
|
|
23
|
+
private routeTraces: RouteTrace[] = [];
|
|
24
|
+
private langfuseClient?: LangfuseClient;
|
|
25
|
+
private langfuseConfig?: LangfuseConfig;
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
super();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Initialize Langfuse if configured
|
|
33
|
+
*/
|
|
34
|
+
initLangfuse(config: LangfuseConfig): void {
|
|
35
|
+
if (!config.publicKey || !config.secretKey) {
|
|
36
|
+
console.warn('[Tracer] Langfuse keys not provided, running without Langfuse');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.langfuseConfig = config;
|
|
41
|
+
|
|
42
|
+
// Simple Langfuse REST client
|
|
43
|
+
this.langfuseClient = {
|
|
44
|
+
trace: async (data: any) => {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(`${config.baseUrl || 'https://cloud.langfuse.com'}/api/public/ingestion`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'Authorization': `Bearer ${config.publicKey}`,
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify(data),
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
console.warn('[Tracer] Langfuse ingestion failed:', response.status);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn('[Tracer] Langfuse error:', err);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
flush: async () => {
|
|
62
|
+
// Langfuse uses async ingestion, noop here
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
console.log('[Tracer] Langfuse initialized');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a new trace span
|
|
71
|
+
*/
|
|
72
|
+
startSpan(operationName: string, parentSpanId?: string): Span {
|
|
73
|
+
const traceId = parentSpanId
|
|
74
|
+
? this.traces.get(parentSpanId)?.traceId || this.generateTraceId()
|
|
75
|
+
: this.generateTraceId();
|
|
76
|
+
|
|
77
|
+
const span: Span = {
|
|
78
|
+
traceId,
|
|
79
|
+
spanId: this.generateTraceId(),
|
|
80
|
+
parentSpanId,
|
|
81
|
+
operationName,
|
|
82
|
+
startTime: Date.now(),
|
|
83
|
+
attributes: {},
|
|
84
|
+
status: 'started',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.traces.set(span.spanId, span);
|
|
88
|
+
this.emit('span_started', span);
|
|
89
|
+
this.emit('observability_event', { type: 'span_started', span } as ObservabilityEvent);
|
|
90
|
+
|
|
91
|
+
return span;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* End a span
|
|
96
|
+
*/
|
|
97
|
+
endSpan(spanId: string, attributes?: Record<string, any>): void {
|
|
98
|
+
const span = this.traces.get(spanId);
|
|
99
|
+
if (!span) {
|
|
100
|
+
console.warn(`[Tracer] span ${spanId} not found`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
span.endTime = Date.now();
|
|
105
|
+
span.duration = span.endTime - span.startTime;
|
|
106
|
+
span.status = 'completed';
|
|
107
|
+
|
|
108
|
+
if (attributes) {
|
|
109
|
+
span.attributes = { ...span.attributes, ...attributes };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.emit('span_completed', span);
|
|
113
|
+
this.emit('observability_event', { type: 'span_completed', span } as ObservabilityEvent);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Record an error on a span
|
|
118
|
+
*/
|
|
119
|
+
errorSpan(spanId: string, error: string, attributes?: Record<string, any>): void {
|
|
120
|
+
const span = this.traces.get(spanId);
|
|
121
|
+
if (!span) {
|
|
122
|
+
console.warn(`[Tracer] span ${spanId} not found`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
span.endTime = Date.now();
|
|
127
|
+
span.duration = span.endTime - span.startTime;
|
|
128
|
+
span.status = 'error';
|
|
129
|
+
span.error = error;
|
|
130
|
+
|
|
131
|
+
if (attributes) {
|
|
132
|
+
span.attributes = { ...span.attributes, ...attributes };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.emit('error', spanId, error);
|
|
136
|
+
this.emit('observability_event', { type: 'error', spanId, error } as ObservabilityEvent);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Record a route decision
|
|
141
|
+
*/
|
|
142
|
+
recordRoute(trace: RouteTrace): void {
|
|
143
|
+
this.routeTraces.push(trace);
|
|
144
|
+
|
|
145
|
+
// Emit event
|
|
146
|
+
this.emit('route_complete', trace);
|
|
147
|
+
this.emit('observability_event', { type: 'route_complete', trace } as ObservabilityEvent);
|
|
148
|
+
|
|
149
|
+
// Export to Langfuse async
|
|
150
|
+
if (this.langfuseClient) {
|
|
151
|
+
this.langfuseClient.trace({
|
|
152
|
+
name: 'route_decision',
|
|
153
|
+
traceId: trace.traceId,
|
|
154
|
+
timestamp: new Date(trace.timestamp).toISOString(),
|
|
155
|
+
input: { query: trace.query },
|
|
156
|
+
output: {
|
|
157
|
+
model: trace.model,
|
|
158
|
+
provider: trace.provider,
|
|
159
|
+
tokens: trace.queryTokens + trace.responseTokens,
|
|
160
|
+
latencyMs: trace.latencyMs,
|
|
161
|
+
cost: trace.cost,
|
|
162
|
+
},
|
|
163
|
+
metadata: {
|
|
164
|
+
queryTokens: trace.queryTokens,
|
|
165
|
+
responseTokens: trace.responseTokens,
|
|
166
|
+
cacheHit: trace.cacheHit,
|
|
167
|
+
complexity: trace.complexity,
|
|
168
|
+
tier: trace.tier,
|
|
169
|
+
},
|
|
170
|
+
}).catch(() => {}); // Fire and forget
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Generate a unique trace ID
|
|
176
|
+
*/
|
|
177
|
+
generateTraceId(): string {
|
|
178
|
+
return generateId();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get all route traces
|
|
183
|
+
*/
|
|
184
|
+
getTraces(limit?: number): RouteTrace[] {
|
|
185
|
+
if (limit) {
|
|
186
|
+
return this.routeTraces.slice(-limit);
|
|
187
|
+
}
|
|
188
|
+
return [...this.routeTraces];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get all spans for a trace
|
|
193
|
+
*/
|
|
194
|
+
getSpans(traceId: string): Span[] {
|
|
195
|
+
return Array.from(this.traces.values()).filter(s => s.traceId === traceId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Export to Langfuse (async, non-blocking)
|
|
200
|
+
*/
|
|
201
|
+
async flushToLangfuse(): Promise<void> {
|
|
202
|
+
if (this.langfuseClient) {
|
|
203
|
+
await this.langfuseClient.flush();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Clear all traces (for testing)
|
|
209
|
+
*/
|
|
210
|
+
reset(): void {
|
|
211
|
+
this.traces.clear();
|
|
212
|
+
this.routeTraces = [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Singleton instance
|
|
217
|
+
let tracerInstance: Tracer | null = null;
|
|
218
|
+
|
|
219
|
+
export function getTracer(): Tracer {
|
|
220
|
+
if (!tracerInstance) {
|
|
221
|
+
tracerInstance = new Tracer();
|
|
222
|
+
}
|
|
223
|
+
return tracerInstance;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function createTracer(): Tracer {
|
|
227
|
+
tracerInstance = new Tracer();
|
|
228
|
+
return tracerInstance;
|
|
229
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Observability types for A3M Router
|
|
2
|
+
|
|
3
|
+
export interface Span {
|
|
4
|
+
traceId: string;
|
|
5
|
+
spanId: string;
|
|
6
|
+
parentSpanId?: string;
|
|
7
|
+
operationName: string;
|
|
8
|
+
startTime: number;
|
|
9
|
+
endTime?: number;
|
|
10
|
+
duration?: number;
|
|
11
|
+
attributes: Record<string, string | number | boolean>;
|
|
12
|
+
status: 'started' | 'completed' | 'error';
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Metric {
|
|
17
|
+
name: string;
|
|
18
|
+
value: number;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
labels: Record<string, string>;
|
|
21
|
+
type: 'counter' | 'gauge' | 'histogram';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RouteTrace {
|
|
25
|
+
traceId: string;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
query: string;
|
|
28
|
+
queryTokens: number;
|
|
29
|
+
model: string;
|
|
30
|
+
provider: string;
|
|
31
|
+
responseTokens: number;
|
|
32
|
+
latencyMs: number;
|
|
33
|
+
cost: number;
|
|
34
|
+
cacheHit: boolean;
|
|
35
|
+
complexity: number;
|
|
36
|
+
tier: 'budget' | 'standard' | 'premium';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Event types emitted by Tracer
|
|
40
|
+
export type ObservabilityEvent =
|
|
41
|
+
| { type: 'route_complete'; trace: RouteTrace }
|
|
42
|
+
| { type: 'span_started'; span: Span }
|
|
43
|
+
| { type: 'span_completed'; span: Span }
|
|
44
|
+
| { type: 'budget_warning'; apiKey: string; usedCents: number; thresholdCents: number }
|
|
45
|
+
| { type: 'error'; spanId: string; error: string };
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// Observability tests
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getTracer,
|
|
5
|
+
createTracer,
|
|
6
|
+
getMetrics,
|
|
7
|
+
createMetricsCollector,
|
|
8
|
+
Tracer,
|
|
9
|
+
MetricsCollector,
|
|
10
|
+
RouteTrace,
|
|
11
|
+
} from '../src/observability';
|
|
12
|
+
|
|
13
|
+
// Use a separate tracer instance for tests
|
|
14
|
+
function createTestTracer(): Tracer {
|
|
15
|
+
const tracer = new Tracer();
|
|
16
|
+
return tracer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createTestMetrics(): MetricsCollector {
|
|
20
|
+
return new MetricsCollector();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Tracer', () => {
|
|
24
|
+
let tracer: Tracer;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tracer = createTestTracer();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
tracer.reset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('startSpan', () => {
|
|
35
|
+
it('should create a new span', () => {
|
|
36
|
+
const span = tracer.startSpan('test_operation');
|
|
37
|
+
|
|
38
|
+
expect(span).toBeDefined();
|
|
39
|
+
expect(span.spanId).toBeDefined();
|
|
40
|
+
expect(span.traceId).toBeDefined();
|
|
41
|
+
expect(span.operationName).toBe('test_operation');
|
|
42
|
+
expect(span.status).toBe('started');
|
|
43
|
+
expect(span.startTime).toBeDefined();
|
|
44
|
+
expect(span.attributes).toEqual({});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should create span with parent', () => {
|
|
48
|
+
const parent = tracer.startSpan('parent_op');
|
|
49
|
+
const child = tracer.startSpan('child_op', parent.spanId);
|
|
50
|
+
|
|
51
|
+
expect(child.parentSpanId).toBe(parent.spanId);
|
|
52
|
+
expect(child.traceId).toBe(parent.traceId);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should emit span_started event', (done) => {
|
|
56
|
+
tracer.on('span_started', (span) => {
|
|
57
|
+
expect(span.operationName).toBe('test_op');
|
|
58
|
+
done();
|
|
59
|
+
});
|
|
60
|
+
tracer.startSpan('test_op');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('endSpan', () => {
|
|
65
|
+
it('should end a span with duration', () => {
|
|
66
|
+
const span = tracer.startSpan('test_op');
|
|
67
|
+
tracer.endSpan(span.spanId);
|
|
68
|
+
|
|
69
|
+
const ended = tracer.getSpans(span.traceId)[0];
|
|
70
|
+
expect(ended.status).toBe('completed');
|
|
71
|
+
expect(ended.duration).toBeDefined();
|
|
72
|
+
expect(ended.endTime).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should merge attributes on end', () => {
|
|
76
|
+
const span = tracer.startSpan('test_op');
|
|
77
|
+
tracer.endSpan(span.spanId, { custom_attr: 'value' });
|
|
78
|
+
|
|
79
|
+
const ended = tracer.getSpans(span.traceId)[0];
|
|
80
|
+
expect(ended.attributes.custom_attr).toBe('value');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should emit span_completed event', (done) => {
|
|
84
|
+
tracer.on('span_completed', (span) => {
|
|
85
|
+
expect(span.status).toBe('completed');
|
|
86
|
+
done();
|
|
87
|
+
});
|
|
88
|
+
const span = tracer.startSpan('test_op');
|
|
89
|
+
tracer.endSpan(span.spanId);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('errorSpan', () => {
|
|
94
|
+
it('should mark span as error', () => {
|
|
95
|
+
const span = tracer.startSpan('test_op');
|
|
96
|
+
tracer.errorSpan(span.spanId, 'something went wrong');
|
|
97
|
+
|
|
98
|
+
const errored = tracer.getSpans(span.traceId)[0];
|
|
99
|
+
expect(errored.status).toBe('error');
|
|
100
|
+
expect(errored.error).toBe('something went wrong');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('recordRoute', () => {
|
|
105
|
+
it('should record a route trace', () => {
|
|
106
|
+
const trace: RouteTrace = {
|
|
107
|
+
traceId: 'trace-123',
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
query: 'Hello world',
|
|
110
|
+
queryTokens: 5,
|
|
111
|
+
model: 'gpt-4o',
|
|
112
|
+
provider: 'openai',
|
|
113
|
+
responseTokens: 100,
|
|
114
|
+
latencyMs: 500,
|
|
115
|
+
cost: 0.03,
|
|
116
|
+
cacheHit: false,
|
|
117
|
+
complexity: 0.5,
|
|
118
|
+
tier: 'premium',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
tracer.recordRoute(trace);
|
|
122
|
+
|
|
123
|
+
const traces = tracer.getTraces();
|
|
124
|
+
expect(traces).toHaveLength(1);
|
|
125
|
+
expect(traces[0].model).toBe('gpt-4o');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should emit route_complete event', (done) => {
|
|
129
|
+
tracer.on('route_complete', (trace) => {
|
|
130
|
+
expect(trace.model).toBe('test-model');
|
|
131
|
+
done();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
tracer.recordRoute({
|
|
135
|
+
traceId: 'test',
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
query: 'test',
|
|
138
|
+
queryTokens: 1,
|
|
139
|
+
model: 'test-model',
|
|
140
|
+
provider: 'test-provider',
|
|
141
|
+
responseTokens: 1,
|
|
142
|
+
latencyMs: 100,
|
|
143
|
+
cost: 0.01,
|
|
144
|
+
cacheHit: false,
|
|
145
|
+
complexity: 0.1,
|
|
146
|
+
tier: 'standard',
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('generateTraceId', () => {
|
|
152
|
+
it('should generate unique IDs', () => {
|
|
153
|
+
const id1 = tracer.generateTraceId();
|
|
154
|
+
const id2 = tracer.generateTraceId();
|
|
155
|
+
expect(id1).not.toBe(id2);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('MetricsCollector', () => {
|
|
161
|
+
let metrics: MetricsCollector;
|
|
162
|
+
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
metrics = createTestMetrics();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
metrics.reset();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('incrementCounter', () => {
|
|
172
|
+
it('should increment a counter', () => {
|
|
173
|
+
metrics.incrementCounter('test_counter');
|
|
174
|
+
metrics.incrementCounter('test_counter');
|
|
175
|
+
|
|
176
|
+
const allMetrics = metrics.getMetrics();
|
|
177
|
+
const counter = allMetrics.find(m => m.name.includes('test_counter'));
|
|
178
|
+
expect(counter?.value).toBe(2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should handle labels', () => {
|
|
182
|
+
metrics.incrementCounter('requests_total', { model: 'gpt-4', provider: 'openai' });
|
|
183
|
+
metrics.incrementCounter('requests_total', { model: 'gpt-4', provider: 'openai' });
|
|
184
|
+
metrics.incrementCounter('requests_total', { model: 'claude-3', provider: 'anthropic' });
|
|
185
|
+
|
|
186
|
+
const allMetrics = metrics.getMetrics();
|
|
187
|
+
const gpt4Metrics = allMetrics.filter(m =>
|
|
188
|
+
m.labels.model === 'gpt-4' && m.labels.provider === 'openai'
|
|
189
|
+
);
|
|
190
|
+
const claudeMetrics = allMetrics.filter(m => m.labels.model === 'claude-3');
|
|
191
|
+
|
|
192
|
+
expect(gpt4Metrics[0]?.value).toBe(2);
|
|
193
|
+
expect(claudeMetrics[0]?.value).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('setGauge', () => {
|
|
198
|
+
it('should set a gauge value', () => {
|
|
199
|
+
metrics.setGauge('active_providers', 5);
|
|
200
|
+
metrics.setGauge('active_providers', 6);
|
|
201
|
+
|
|
202
|
+
const allMetrics = metrics.getMetrics();
|
|
203
|
+
const gauge = allMetrics.find(m => m.name.includes('active_providers'));
|
|
204
|
+
expect(gauge?.value).toBe(6);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('recordHistogram', () => {
|
|
209
|
+
it('should record histogram values', () => {
|
|
210
|
+
metrics.recordHistogram('latency', 0.5);
|
|
211
|
+
metrics.recordHistogram('latency', 1.0);
|
|
212
|
+
metrics.recordHistogram('latency', 1.5);
|
|
213
|
+
|
|
214
|
+
const allMetrics = metrics.getMetrics();
|
|
215
|
+
const histogram = allMetrics.find(m => m.type === 'histogram');
|
|
216
|
+
expect(histogram).toBeDefined();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('getPrometheusMetrics', () => {
|
|
221
|
+
it('should output Prometheus format', () => {
|
|
222
|
+
metrics.setGauge('a3m_active_providers', 5, { provider: 'openai' });
|
|
223
|
+
metrics.incrementCounter('a3m_router_requests_total', { model: 'gpt-4', provider: 'openai' });
|
|
224
|
+
metrics.recordHistogram('a3m_request_latency_seconds', 0.5);
|
|
225
|
+
|
|
226
|
+
const output = metrics.getPrometheusMetrics();
|
|
227
|
+
|
|
228
|
+
expect(output).toContain('# HELP');
|
|
229
|
+
expect(output).toContain('# TYPE');
|
|
230
|
+
expect(output).toContain('a3m_active_providers');
|
|
231
|
+
expect(output).toContain('a3m_router_requests_total');
|
|
232
|
+
expect(output).toContain('a3m_request_latency_seconds');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should include router info', () => {
|
|
236
|
+
const output = metrics.getPrometheusMetrics();
|
|
237
|
+
expect(output).toContain('a3m_router_info');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should include timestamp', () => {
|
|
241
|
+
const output = metrics.getPrometheusMetrics();
|
|
242
|
+
// Should end with a timestamp number
|
|
243
|
+
const lines = output.trim().split('\n');
|
|
244
|
+
const lastLine = lines[lines.length - 1];
|
|
245
|
+
const parts = lastLine.split(' ');
|
|
246
|
+
const lastValue = parts[parts.length - 1];
|
|
247
|
+
expect(Number(lastValue)).toBeGreaterThan(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('getMetrics', () => {
|
|
252
|
+
it('should return all metrics as objects', () => {
|
|
253
|
+
metrics.incrementCounter('counter_test', { label: 'value' });
|
|
254
|
+
metrics.setGauge('gauge_test', 42);
|
|
255
|
+
metrics.recordHistogram('histogram_test', 1.0);
|
|
256
|
+
|
|
257
|
+
const allMetrics = metrics.getMetrics();
|
|
258
|
+
|
|
259
|
+
expect(allMetrics.length).toBeGreaterThan(0);
|
|
260
|
+
expect(allMetrics.some(m => m.type === 'counter')).toBe(true);
|
|
261
|
+
expect(allMetrics.some(m => m.type === 'gauge')).toBe(true);
|
|
262
|
+
expect(allMetrics.some(m => m.type === 'histogram')).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should include timestamp', () => {
|
|
266
|
+
metrics.incrementCounter('test_metric');
|
|
267
|
+
const allMetrics = metrics.getMetrics();
|
|
268
|
+
|
|
269
|
+
expect(allMetrics[0].timestamp).toBeGreaterThan(0);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('reset', () => {
|
|
274
|
+
it('should clear all metrics', () => {
|
|
275
|
+
metrics.incrementCounter('test_counter');
|
|
276
|
+
metrics.setGauge('test_gauge', 10);
|
|
277
|
+
metrics.recordHistogram('test_histogram', 1.0);
|
|
278
|
+
|
|
279
|
+
metrics.reset();
|
|
280
|
+
|
|
281
|
+
const allMetrics = metrics.getMetrics();
|
|
282
|
+
expect(allMetrics).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('Integration', () => {
|
|
288
|
+
it('should track route metrics end-to-end', () => {
|
|
289
|
+
const tracer = createTestTracer();
|
|
290
|
+
const metrics = createTestMetrics();
|
|
291
|
+
|
|
292
|
+
const trace: RouteTrace = {
|
|
293
|
+
traceId: 'e2e-trace',
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
query: 'Hello AI',
|
|
296
|
+
queryTokens: 3,
|
|
297
|
+
model: 'gpt-4o',
|
|
298
|
+
provider: 'openai',
|
|
299
|
+
responseTokens: 50,
|
|
300
|
+
latencyMs: 250,
|
|
301
|
+
cost: 0.015,
|
|
302
|
+
cacheHit: false,
|
|
303
|
+
complexity: 0.3,
|
|
304
|
+
tier: 'premium',
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Record the route
|
|
308
|
+
tracer.recordRoute(trace);
|
|
309
|
+
|
|
310
|
+
// Record metrics for this route
|
|
311
|
+
metrics.incrementCounter('a3m_router_requests_total', {
|
|
312
|
+
model: trace.model,
|
|
313
|
+
provider: trace.provider,
|
|
314
|
+
tier: trace.tier,
|
|
315
|
+
cache_hit: String(trace.cacheHit),
|
|
316
|
+
});
|
|
317
|
+
metrics.recordHistogram('a3m_request_latency_seconds', trace.latencyMs / 1000, {
|
|
318
|
+
model: trace.model,
|
|
319
|
+
provider: trace.provider,
|
|
320
|
+
});
|
|
321
|
+
metrics.recordHistogram('a3m_request_cost_cents', trace.cost * 100, {
|
|
322
|
+
model: trace.model,
|
|
323
|
+
provider: trace.provider,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Verify trace recorded
|
|
327
|
+
const traces = tracer.getTraces();
|
|
328
|
+
expect(traces).toHaveLength(1);
|
|
329
|
+
expect(traces[0].cost).toBe(0.015);
|
|
330
|
+
|
|
331
|
+
// Verify metrics recorded
|
|
332
|
+
const allMetrics = metrics.getMetrics();
|
|
333
|
+
const requestCounter = allMetrics.find(
|
|
334
|
+
m => m.name.includes('a3m_router_requests_total')
|
|
335
|
+
);
|
|
336
|
+
expect(requestCounter?.value).toBe(1);
|
|
337
|
+
|
|
338
|
+
const latencyHist = allMetrics.find(
|
|
339
|
+
m => m.name.includes('a3m_request_latency_seconds')
|
|
340
|
+
);
|
|
341
|
+
expect(latencyHist).toBeDefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should track cache hit/miss', () => {
|
|
345
|
+
const tracer = createTestTracer();
|
|
346
|
+
const metrics = createTestMetrics();
|
|
347
|
+
|
|
348
|
+
// Cache hit
|
|
349
|
+
tracer.recordRoute({
|
|
350
|
+
traceId: 'cache-hit',
|
|
351
|
+
timestamp: Date.now(),
|
|
352
|
+
query: 'cached query',
|
|
353
|
+
queryTokens: 10,
|
|
354
|
+
model: 'gpt-4o-mini',
|
|
355
|
+
provider: 'openai',
|
|
356
|
+
responseTokens: 20,
|
|
357
|
+
latencyMs: 50,
|
|
358
|
+
cost: 0,
|
|
359
|
+
cacheHit: true,
|
|
360
|
+
complexity: 0.2,
|
|
361
|
+
tier: 'budget',
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Cache miss
|
|
365
|
+
tracer.recordRoute({
|
|
366
|
+
traceId: 'cache-miss',
|
|
367
|
+
timestamp: Date.now(),
|
|
368
|
+
query: 'new query',
|
|
369
|
+
queryTokens: 10,
|
|
370
|
+
model: 'gpt-4o',
|
|
371
|
+
provider: 'openai',
|
|
372
|
+
responseTokens: 100,
|
|
373
|
+
latencyMs: 500,
|
|
374
|
+
cost: 0.02,
|
|
375
|
+
cacheHit: false,
|
|
376
|
+
complexity: 0.5,
|
|
377
|
+
tier: 'premium',
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
metrics.incrementCounter('a3m_cache_hits_total');
|
|
381
|
+
metrics.incrementCounter('a3m_cache_misses_total');
|
|
382
|
+
|
|
383
|
+
const allMetrics = metrics.getMetrics();
|
|
384
|
+
const hits = allMetrics.find(m => m.name === 'a3m_cache_hits_total');
|
|
385
|
+
const misses = allMetrics.find(m => m.name === 'a3m_cache_misses_total');
|
|
386
|
+
|
|
387
|
+
expect(hits?.value).toBe(1);
|
|
388
|
+
expect(misses?.value).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
});
|