adaptive-memory-multi-model-router 2.8.0 → 2.9.1
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 +73 -42
- 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,164 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { getTracer, createTracer } from './tracer';
|
|
3
|
+
import { getMetrics, createMetricsCollector } from './metrics';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Express middleware for observability
|
|
7
|
+
* - Adds trace ID to all requests
|
|
8
|
+
* - Records request/response metrics
|
|
9
|
+
* - Attaches span context
|
|
10
|
+
*/
|
|
11
|
+
export function observabilityMiddleware(
|
|
12
|
+
req: Request,
|
|
13
|
+
res: Response,
|
|
14
|
+
next: NextFunction
|
|
15
|
+
): void {
|
|
16
|
+
const tracer = getTracer();
|
|
17
|
+
const metrics = getMetrics();
|
|
18
|
+
|
|
19
|
+
// Generate or extract trace ID
|
|
20
|
+
const traceId = (req.headers['x-trace-id'] as string) || tracer.generateTraceId();
|
|
21
|
+
|
|
22
|
+
// Attach to request object
|
|
23
|
+
(req as any).traceId = traceId;
|
|
24
|
+
(req as any).spanId = null;
|
|
25
|
+
|
|
26
|
+
// Add trace ID to response headers
|
|
27
|
+
res.setHeader('X-Trace-ID', traceId);
|
|
28
|
+
|
|
29
|
+
// Start span for this request
|
|
30
|
+
const span = tracer.startSpan(`${req.method} ${req.path}`);
|
|
31
|
+
(req as any).spanId = span.spanId;
|
|
32
|
+
(req as any).span = span;
|
|
33
|
+
|
|
34
|
+
// Record start time for latency calculation
|
|
35
|
+
const startTime = Date.now();
|
|
36
|
+
|
|
37
|
+
// Hook into response finish
|
|
38
|
+
res.on('finish', () => {
|
|
39
|
+
const duration = Date.now() - startTime;
|
|
40
|
+
|
|
41
|
+
// End the span
|
|
42
|
+
tracer.endSpan(span.spanId, {
|
|
43
|
+
method: req.method,
|
|
44
|
+
path: req.path,
|
|
45
|
+
statusCode: res.statusCode,
|
|
46
|
+
durationMs: duration,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Record metrics
|
|
50
|
+
const model = (req as any).model || 'unknown';
|
|
51
|
+
const provider = (req as any).provider || 'unknown';
|
|
52
|
+
const cacheHit = (req as any).cacheHit || false;
|
|
53
|
+
const tier = (req as any).tier || 'standard';
|
|
54
|
+
|
|
55
|
+
// Request counter
|
|
56
|
+
metrics.incrementCounter('a3m_router_requests_total', {
|
|
57
|
+
model,
|
|
58
|
+
provider,
|
|
59
|
+
tier,
|
|
60
|
+
cache_hit: String(cacheHit),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Latency histogram (convert to seconds)
|
|
64
|
+
metrics.recordHistogram('a3m_request_latency_seconds', duration / 1000, {
|
|
65
|
+
model,
|
|
66
|
+
provider,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Error counter
|
|
70
|
+
if (res.statusCode >= 400) {
|
|
71
|
+
metrics.incrementCounter('a3m_router_errors_total', {
|
|
72
|
+
provider,
|
|
73
|
+
error_type: res.statusCode >= 500 ? 'server_error' : 'client_error',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
next();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fastify plugin for observability middleware
|
|
83
|
+
*/
|
|
84
|
+
export function observabilityPlugin(
|
|
85
|
+
instance: any,
|
|
86
|
+
options: any,
|
|
87
|
+
next: (err?: Error) => void
|
|
88
|
+
): void {
|
|
89
|
+
instance.addHook('onRequest', async (request: any, reply: any) => {
|
|
90
|
+
const tracer = getTracer();
|
|
91
|
+
const metrics = getMetrics();
|
|
92
|
+
|
|
93
|
+
const traceId = request.headers['x-trace-id'] || tracer.generateTraceId();
|
|
94
|
+
request.traceId = traceId;
|
|
95
|
+
|
|
96
|
+
reply.header('X-Trace-ID', traceId);
|
|
97
|
+
|
|
98
|
+
const span = tracer.startSpan(`${request.method} ${request.url}`);
|
|
99
|
+
request.spanId = span.spanId;
|
|
100
|
+
request.span = span;
|
|
101
|
+
request.startTime = Date.now();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
instance.addHook('onResponse', async (request: any, reply: any) => {
|
|
105
|
+
const tracer = getTracer();
|
|
106
|
+
const metrics = getMetrics();
|
|
107
|
+
|
|
108
|
+
const duration = Date.now() - request.startTime;
|
|
109
|
+
|
|
110
|
+
if (request.spanId) {
|
|
111
|
+
tracer.endSpan(request.spanId, {
|
|
112
|
+
method: request.method,
|
|
113
|
+
path: request.url,
|
|
114
|
+
statusCode: reply.statusCode,
|
|
115
|
+
durationMs: duration,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const model = request.model || 'unknown';
|
|
120
|
+
const provider = request.provider || 'unknown';
|
|
121
|
+
const cacheHit = request.cacheHit || false;
|
|
122
|
+
const tier = request.tier || 'standard';
|
|
123
|
+
|
|
124
|
+
metrics.incrementCounter('a3m_router_requests_total', {
|
|
125
|
+
model,
|
|
126
|
+
provider,
|
|
127
|
+
tier,
|
|
128
|
+
cache_hit: String(cacheHit),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
metrics.recordHistogram('a3m_request_latency_seconds', duration / 1000, {
|
|
132
|
+
model,
|
|
133
|
+
provider,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (reply.statusCode >= 400) {
|
|
137
|
+
metrics.incrementCounter('a3m_router_errors_total', {
|
|
138
|
+
provider,
|
|
139
|
+
error_type: reply.statusCode >= 500 ? 'server_error' : 'client_error',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
next();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Middleware for budget warning alerts
|
|
149
|
+
*/
|
|
150
|
+
export function budgetAlertMiddleware(
|
|
151
|
+
req: Request,
|
|
152
|
+
res: Response,
|
|
153
|
+
next: NextFunction
|
|
154
|
+
): void {
|
|
155
|
+
const tracer = getTracer();
|
|
156
|
+
|
|
157
|
+
// Hook into cost tracking to emit budget warnings
|
|
158
|
+
tracer.on('route_complete', (trace: any) => {
|
|
159
|
+
// This would be connected to budget enforcement in practice
|
|
160
|
+
// For now just a placeholder for the event hook
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
next();
|
|
164
|
+
}
|
|
@@ -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 };
|