@mcploom/analytics 0.2.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/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/index.cjs +685 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +199 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +685 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { appendFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
//#region src/utils.ts
|
|
4
|
+
/**
|
|
5
|
+
* Returns the byte length of a string (UTF-8).
|
|
6
|
+
*/
|
|
7
|
+
function byteSize(value) {
|
|
8
|
+
if (value === void 0 || value === null) return 0;
|
|
9
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
10
|
+
return new TextEncoder().encode(str).byteLength;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Compute a percentile from a sorted array of numbers.
|
|
14
|
+
* Uses linear interpolation between the closest ranks.
|
|
15
|
+
*/
|
|
16
|
+
function percentile(sorted, p) {
|
|
17
|
+
if (sorted.length === 0) return 0;
|
|
18
|
+
if (sorted.length === 1) return sorted[0];
|
|
19
|
+
const index = p / 100 * (sorted.length - 1);
|
|
20
|
+
const lower = Math.floor(index);
|
|
21
|
+
const upper = Math.ceil(index);
|
|
22
|
+
if (lower === upper) return sorted[lower];
|
|
23
|
+
const weight = index - lower;
|
|
24
|
+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/collector.ts
|
|
29
|
+
/**
|
|
30
|
+
* In-memory ring buffer that collects ToolCallEvents, computes stats,
|
|
31
|
+
* and periodically flushes to an exporter.
|
|
32
|
+
*/
|
|
33
|
+
var Collector = class {
|
|
34
|
+
buffer = [];
|
|
35
|
+
accumulators = /* @__PURE__ */ new Map();
|
|
36
|
+
sessionAccumulators = /* @__PURE__ */ new Map();
|
|
37
|
+
totalCalls = 0;
|
|
38
|
+
totalErrors = 0;
|
|
39
|
+
startTime = Date.now();
|
|
40
|
+
flushTimer;
|
|
41
|
+
/** Events accumulated since last flush, to be sent to the exporter */
|
|
42
|
+
pending = [];
|
|
43
|
+
flushInFlight;
|
|
44
|
+
toolWindowSize;
|
|
45
|
+
onFlushError;
|
|
46
|
+
constructor(maxBufferSize, exporter, flushIntervalMs, options = {}) {
|
|
47
|
+
this.maxBufferSize = maxBufferSize;
|
|
48
|
+
this.exporter = exporter;
|
|
49
|
+
this.toolWindowSize = Math.max(1, options.toolWindowSize ?? 2048);
|
|
50
|
+
this.onFlushError = options.onFlushError ?? ((error) => {
|
|
51
|
+
console.error("[McpAnalytics] Exporter flush failed:", error);
|
|
52
|
+
});
|
|
53
|
+
if (flushIntervalMs > 0) {
|
|
54
|
+
this.flushTimer = setInterval(() => {
|
|
55
|
+
this.flush().catch((error) => {
|
|
56
|
+
this.onFlushError(error);
|
|
57
|
+
});
|
|
58
|
+
}, flushIntervalMs);
|
|
59
|
+
if (this.flushTimer && typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Record a new tool call event.
|
|
64
|
+
*/
|
|
65
|
+
record(event) {
|
|
66
|
+
if (this.maxBufferSize > 0) {
|
|
67
|
+
if (this.buffer.length >= this.maxBufferSize) this.buffer.shift();
|
|
68
|
+
this.buffer.push(event);
|
|
69
|
+
}
|
|
70
|
+
this.pending.push(event);
|
|
71
|
+
this.totalCalls++;
|
|
72
|
+
if (!event.success) this.totalErrors++;
|
|
73
|
+
this.updateToolAccumulator(this.accumulators, event.toolName, event);
|
|
74
|
+
const sessionKey = event.sessionId ?? "unknown";
|
|
75
|
+
let sessionAcc = this.sessionAccumulators.get(sessionKey);
|
|
76
|
+
if (!sessionAcc) {
|
|
77
|
+
sessionAcc = {
|
|
78
|
+
...this.newAccumulator(),
|
|
79
|
+
tools: /* @__PURE__ */ new Map()
|
|
80
|
+
};
|
|
81
|
+
this.sessionAccumulators.set(sessionKey, sessionAcc);
|
|
82
|
+
}
|
|
83
|
+
this.updateAccumulator(sessionAcc, event);
|
|
84
|
+
this.updateToolAccumulator(sessionAcc.tools, event.toolName, event);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Get aggregated stats for all tools.
|
|
88
|
+
*/
|
|
89
|
+
getStats() {
|
|
90
|
+
const tools = {};
|
|
91
|
+
for (const [name, acc] of this.accumulators) tools[name] = this.accToStats(acc);
|
|
92
|
+
const sessions = {};
|
|
93
|
+
for (const [sessionId, acc] of this.sessionAccumulators) sessions[sessionId] = this.sessionAccToStats(acc);
|
|
94
|
+
return {
|
|
95
|
+
totalCalls: this.totalCalls,
|
|
96
|
+
totalErrors: this.totalErrors,
|
|
97
|
+
errorRate: this.totalCalls > 0 ? this.totalErrors / this.totalCalls : 0,
|
|
98
|
+
uptimeMs: Date.now() - this.startTime,
|
|
99
|
+
tools,
|
|
100
|
+
sessions
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get stats for a single tool.
|
|
105
|
+
*/
|
|
106
|
+
getToolStats(toolName) {
|
|
107
|
+
const acc = this.accumulators.get(toolName);
|
|
108
|
+
if (!acc) return void 0;
|
|
109
|
+
return this.accToStats(acc);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Get stats for a single session.
|
|
113
|
+
*/
|
|
114
|
+
getSessionStats(sessionId) {
|
|
115
|
+
const acc = this.sessionAccumulators.get(sessionId);
|
|
116
|
+
if (!acc) return void 0;
|
|
117
|
+
return this.sessionAccToStats(acc);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get top sessions ordered by total call count.
|
|
121
|
+
*/
|
|
122
|
+
getTopSessions(limit = 10) {
|
|
123
|
+
if (limit <= 0) return [];
|
|
124
|
+
return [...this.sessionAccumulators.entries()].sort((a, b) => {
|
|
125
|
+
const byCount = b[1].count - a[1].count;
|
|
126
|
+
if (byCount !== 0) return byCount;
|
|
127
|
+
return b[1].lastCalledAt - a[1].lastCalledAt;
|
|
128
|
+
}).slice(0, limit).map(([sessionId, acc]) => ({
|
|
129
|
+
sessionId,
|
|
130
|
+
stats: this.sessionAccToStats(acc)
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Flush pending events to the exporter.
|
|
135
|
+
*/
|
|
136
|
+
async flush() {
|
|
137
|
+
if (this.flushInFlight) {
|
|
138
|
+
await this.flushInFlight;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const run = this.flushPending();
|
|
142
|
+
this.flushInFlight = run;
|
|
143
|
+
try {
|
|
144
|
+
await run;
|
|
145
|
+
} finally {
|
|
146
|
+
if (this.flushInFlight === run) this.flushInFlight = void 0;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Reset all collected data.
|
|
151
|
+
*/
|
|
152
|
+
reset() {
|
|
153
|
+
this.buffer.length = 0;
|
|
154
|
+
this.pending.length = 0;
|
|
155
|
+
this.accumulators.clear();
|
|
156
|
+
this.sessionAccumulators.clear();
|
|
157
|
+
this.totalCalls = 0;
|
|
158
|
+
this.totalErrors = 0;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Stop the flush timer and flush remaining events.
|
|
162
|
+
*/
|
|
163
|
+
async destroy() {
|
|
164
|
+
if (this.flushTimer) {
|
|
165
|
+
clearInterval(this.flushTimer);
|
|
166
|
+
this.flushTimer = void 0;
|
|
167
|
+
}
|
|
168
|
+
await this.flush();
|
|
169
|
+
}
|
|
170
|
+
async flushPending() {
|
|
171
|
+
while (this.pending.length > 0) {
|
|
172
|
+
const batch = this.pending;
|
|
173
|
+
this.pending = [];
|
|
174
|
+
try {
|
|
175
|
+
await this.exporter(batch);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
this.pending = batch.concat(this.pending);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
newAccumulator() {
|
|
183
|
+
return {
|
|
184
|
+
count: 0,
|
|
185
|
+
errorCount: 0,
|
|
186
|
+
totalMs: 0,
|
|
187
|
+
durations: [],
|
|
188
|
+
lastCalledAt: 0
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
updateToolAccumulator(map, toolName, event) {
|
|
192
|
+
let acc = map.get(toolName);
|
|
193
|
+
if (!acc) {
|
|
194
|
+
acc = this.newAccumulator();
|
|
195
|
+
map.set(toolName, acc);
|
|
196
|
+
}
|
|
197
|
+
this.updateAccumulator(acc, event);
|
|
198
|
+
}
|
|
199
|
+
updateAccumulator(acc, event) {
|
|
200
|
+
acc.count++;
|
|
201
|
+
if (!event.success) acc.errorCount++;
|
|
202
|
+
acc.totalMs += event.durationMs;
|
|
203
|
+
acc.durations.push(event.durationMs);
|
|
204
|
+
if (acc.durations.length > this.toolWindowSize) acc.durations.shift();
|
|
205
|
+
acc.lastCalledAt = event.timestamp;
|
|
206
|
+
}
|
|
207
|
+
accToStats(acc) {
|
|
208
|
+
const sortedDurations = [...acc.durations].sort((a, b) => a - b);
|
|
209
|
+
return {
|
|
210
|
+
count: acc.count,
|
|
211
|
+
errorCount: acc.errorCount,
|
|
212
|
+
errorRate: acc.count > 0 ? acc.errorCount / acc.count : 0,
|
|
213
|
+
p50Ms: percentile(sortedDurations, 50),
|
|
214
|
+
p95Ms: percentile(sortedDurations, 95),
|
|
215
|
+
p99Ms: percentile(sortedDurations, 99),
|
|
216
|
+
avgMs: acc.count > 0 ? acc.totalMs / acc.count : 0,
|
|
217
|
+
lastCalledAt: acc.lastCalledAt
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
sessionAccToStats(acc) {
|
|
221
|
+
const tools = {};
|
|
222
|
+
for (const [toolName, toolAcc] of acc.tools) tools[toolName] = this.accToStats(toolAcc);
|
|
223
|
+
return {
|
|
224
|
+
count: acc.count,
|
|
225
|
+
errorCount: acc.errorCount,
|
|
226
|
+
errorRate: acc.count > 0 ? acc.errorCount / acc.count : 0,
|
|
227
|
+
avgMs: acc.count > 0 ? acc.totalMs / acc.count : 0,
|
|
228
|
+
lastCalledAt: acc.lastCalledAt,
|
|
229
|
+
tools
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/exporters/console.ts
|
|
236
|
+
/**
|
|
237
|
+
* Console exporter: pretty-prints each batch of events to stdout.
|
|
238
|
+
*/
|
|
239
|
+
function createConsoleExporter() {
|
|
240
|
+
return async (events) => {
|
|
241
|
+
if (events.length === 0) return;
|
|
242
|
+
const lines = ["[McpAnalytics] Flushing batch:"];
|
|
243
|
+
for (const e of events) {
|
|
244
|
+
const errorSuffix = e.errorCode ? ` (${e.errorCode})` : "";
|
|
245
|
+
const status = e.success ? "OK" : `ERR${errorSuffix}`;
|
|
246
|
+
const meta = e.sessionId ? ` session=${e.sessionId}` : "";
|
|
247
|
+
lines.push(` ${e.toolName} ${status} ${e.durationMs}ms in=${e.inputSize}B out=${e.outputSize}B${meta}`);
|
|
248
|
+
}
|
|
249
|
+
console.log(lines.join("\n"));
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/exporters/custom.ts
|
|
255
|
+
/**
|
|
256
|
+
* Wraps a user-provided export function, catching errors to prevent
|
|
257
|
+
* exporter failures from disrupting the MCP server.
|
|
258
|
+
*/
|
|
259
|
+
function createCustomExporter(fn) {
|
|
260
|
+
return async (events) => {
|
|
261
|
+
try {
|
|
262
|
+
await fn(events);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error("[McpAnalytics] Custom exporter error:", err);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/exporters/json.ts
|
|
271
|
+
/**
|
|
272
|
+
* JSON exporter: appends events as JSONL (one JSON object per line) to a file.
|
|
273
|
+
*/
|
|
274
|
+
function createJsonExporter(config) {
|
|
275
|
+
return async (events) => {
|
|
276
|
+
if (events.length === 0) return;
|
|
277
|
+
const lines = events.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
278
|
+
await appendFile(config.path, lines, "utf-8");
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/exporters/otlp.ts
|
|
284
|
+
/**
|
|
285
|
+
* OTLP exporter: sends tool call events as OpenTelemetry spans.
|
|
286
|
+
*
|
|
287
|
+
* Uses dynamic imports so that @opentelemetry/* packages are only loaded
|
|
288
|
+
* when this exporter is actually used.
|
|
289
|
+
*/
|
|
290
|
+
function createOtlpExporter(config) {
|
|
291
|
+
let tracerPromise;
|
|
292
|
+
return async (events) => {
|
|
293
|
+
if (events.length === 0) return;
|
|
294
|
+
if (!tracerPromise) tracerPromise = initTracer(config);
|
|
295
|
+
const tracer = await tracerPromise;
|
|
296
|
+
for (const event of events) tracer.exportEvent(event);
|
|
297
|
+
await tracer.flush();
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function initTracer(config) {
|
|
301
|
+
const { SpanStatusCode } = await import("@opentelemetry/api");
|
|
302
|
+
const { BasicTracerProvider, SimpleSpanProcessor } = await import("@opentelemetry/sdk-trace-base");
|
|
303
|
+
const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
|
|
304
|
+
const otlpExporter = new OTLPTraceExporter({
|
|
305
|
+
url: config.endpoint,
|
|
306
|
+
headers: config.headers
|
|
307
|
+
});
|
|
308
|
+
const tracer = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(otlpExporter)] }).getTracer("@mcploom/analytics");
|
|
309
|
+
return {
|
|
310
|
+
exportEvent(event) {
|
|
311
|
+
const span = tracer.startSpan("mcp.tool_call", {
|
|
312
|
+
startTime: new Date(event.timestamp),
|
|
313
|
+
attributes: {
|
|
314
|
+
"mcp.tool.name": event.toolName,
|
|
315
|
+
"mcp.tool.duration_ms": event.durationMs,
|
|
316
|
+
"mcp.tool.success": event.success,
|
|
317
|
+
"mcp.tool.input_size": event.inputSize,
|
|
318
|
+
"mcp.tool.output_size": event.outputSize,
|
|
319
|
+
...event.sessionId && { "mcp.session.id": event.sessionId },
|
|
320
|
+
...event.errorMessage && { "mcp.tool.error_message": event.errorMessage },
|
|
321
|
+
...event.errorCode !== void 0 && { "mcp.tool.error_code": event.errorCode },
|
|
322
|
+
...Object.fromEntries(Object.entries(event.metadata ?? {}).map(([k, v]) => [`mcp.meta.${k}`, v]))
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
if (!event.success) span.setStatus({
|
|
326
|
+
code: SpanStatusCode.ERROR,
|
|
327
|
+
message: event.errorMessage
|
|
328
|
+
});
|
|
329
|
+
span.end(new Date(event.timestamp + event.durationMs));
|
|
330
|
+
},
|
|
331
|
+
async flush() {
|
|
332
|
+
await otlpExporter?.forceFlush?.();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/tracing.ts
|
|
339
|
+
let otelApi;
|
|
340
|
+
let otelLoadFailed = false;
|
|
341
|
+
/**
|
|
342
|
+
* Lazily load @opentelemetry/api. Returns undefined if the package is not installed.
|
|
343
|
+
*/
|
|
344
|
+
async function getOtelApi() {
|
|
345
|
+
if (otelApi) return otelApi;
|
|
346
|
+
if (otelLoadFailed) return void 0;
|
|
347
|
+
try {
|
|
348
|
+
otelApi = await import("@opentelemetry/api");
|
|
349
|
+
return otelApi;
|
|
350
|
+
} catch {
|
|
351
|
+
otelLoadFailed = true;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Start an OpenTelemetry span for a tool call using the global tracer provider.
|
|
357
|
+
* Returns undefined if @opentelemetry/api is not available.
|
|
358
|
+
*/
|
|
359
|
+
async function startToolSpan(toolName, attributes) {
|
|
360
|
+
const api = await getOtelApi();
|
|
361
|
+
if (!api) return void 0;
|
|
362
|
+
const span = api.trace.getTracer("@mcploom/analytics").startSpan("mcp.tool_call", { attributes: {
|
|
363
|
+
"mcp.tool.name": toolName,
|
|
364
|
+
...attributes
|
|
365
|
+
} });
|
|
366
|
+
return {
|
|
367
|
+
span,
|
|
368
|
+
context: api.trace.setSpan(api.context.active(), span)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* End a tool span, setting error status if the call failed.
|
|
373
|
+
*/
|
|
374
|
+
function endToolSpan(tracing, success, errorMessage) {
|
|
375
|
+
if (!success && otelApi) tracing.span.setStatus({
|
|
376
|
+
code: otelApi.SpanStatusCode.ERROR,
|
|
377
|
+
message: errorMessage
|
|
378
|
+
});
|
|
379
|
+
tracing.span.end();
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Run a function within the context of a span, so downstream OTel-instrumented
|
|
383
|
+
* calls become children of this span.
|
|
384
|
+
*/
|
|
385
|
+
async function withSpanContext(tracing, fn) {
|
|
386
|
+
const api = otelApi;
|
|
387
|
+
if (!api) return fn();
|
|
388
|
+
return api.context.with(tracing.context, fn);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/middleware.ts
|
|
393
|
+
/**
|
|
394
|
+
* Checks if a JSON-RPC message is a request (has `id` and `method`).
|
|
395
|
+
*/
|
|
396
|
+
function isRequest(msg) {
|
|
397
|
+
return "id" in msg && "method" in msg;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Checks if a JSON-RPC message is a response with a result.
|
|
401
|
+
*/
|
|
402
|
+
function isResultResponse(msg) {
|
|
403
|
+
return "id" in msg && "result" in msg;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Checks if a JSON-RPC message is an error response.
|
|
407
|
+
*/
|
|
408
|
+
function isErrorResponse(msg) {
|
|
409
|
+
return "id" in msg && "error" in msg;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Wraps a Transport to intercept tools/call requests and their responses,
|
|
413
|
+
* recording metrics to the Collector.
|
|
414
|
+
*/
|
|
415
|
+
function instrumentTransport(transport, collector, sampleRate, globalMetadata, tracing, samplingStrategy = "per_call") {
|
|
416
|
+
const pending = /* @__PURE__ */ new Map();
|
|
417
|
+
const sessionSamplingDecisions = /* @__PURE__ */ new Map();
|
|
418
|
+
const origOnMessage = transport.onmessage;
|
|
419
|
+
const origOnClose = transport.onclose;
|
|
420
|
+
const sampleForSession = (sessionKey) => {
|
|
421
|
+
if (samplingStrategy !== "per_session") return Math.random() < sampleRate;
|
|
422
|
+
const cached = sessionSamplingDecisions.get(sessionKey);
|
|
423
|
+
if (cached !== void 0) return cached;
|
|
424
|
+
const decision = Math.random() < sampleRate;
|
|
425
|
+
sessionSamplingDecisions.set(sessionKey, decision);
|
|
426
|
+
return decision;
|
|
427
|
+
};
|
|
428
|
+
const closePendingCall = (call, success, errorMessage) => {
|
|
429
|
+
if (call.tracing) {
|
|
430
|
+
endToolSpan(call.tracing, success, errorMessage);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (call.tracingInit) call.tracingInit.then((span) => {
|
|
434
|
+
if (span) endToolSpan(span, success, errorMessage);
|
|
435
|
+
}).catch(() => {});
|
|
436
|
+
};
|
|
437
|
+
const cleanupPendingCalls = (reason) => {
|
|
438
|
+
for (const call of pending.values()) closePendingCall(call, false, reason);
|
|
439
|
+
pending.clear();
|
|
440
|
+
sessionSamplingDecisions.clear();
|
|
441
|
+
};
|
|
442
|
+
const proxy = new Proxy(transport, {
|
|
443
|
+
set(target, prop, value) {
|
|
444
|
+
if (prop === "onmessage" && typeof value === "function") {
|
|
445
|
+
const userHandler = value;
|
|
446
|
+
target.onmessage = (message, extra) => {
|
|
447
|
+
if (isRequest(message) && message.method === "tools/call") {
|
|
448
|
+
if (sampleForSession(target.sessionId ?? "unknown")) {
|
|
449
|
+
const params = message.params;
|
|
450
|
+
const toolName = params?.name ?? "unknown";
|
|
451
|
+
const inputSize = byteSize(params?.arguments);
|
|
452
|
+
const pendingCall = {
|
|
453
|
+
toolName,
|
|
454
|
+
startTime: Date.now(),
|
|
455
|
+
inputSize
|
|
456
|
+
};
|
|
457
|
+
if (tracing) pendingCall.tracingInit = startToolSpan(toolName, { "mcp.tool.input_size": inputSize }).then((span) => {
|
|
458
|
+
pendingCall.tracing = span;
|
|
459
|
+
return span;
|
|
460
|
+
}).catch(() => void 0);
|
|
461
|
+
pending.set(message.id, pendingCall);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
userHandler(message, extra);
|
|
465
|
+
};
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
if (prop === "onclose" && typeof value === "function") {
|
|
469
|
+
const userOnClose = value;
|
|
470
|
+
target.onclose = (...args) => {
|
|
471
|
+
cleanupPendingCalls("Transport closed before tool response");
|
|
472
|
+
userOnClose(...args);
|
|
473
|
+
};
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
target[prop] = value;
|
|
477
|
+
return true;
|
|
478
|
+
},
|
|
479
|
+
get(target, prop, receiver) {
|
|
480
|
+
if (prop === "send") return async (message, options) => {
|
|
481
|
+
if ("id" in message) {
|
|
482
|
+
const id = message.id;
|
|
483
|
+
const call = pending.get(id);
|
|
484
|
+
if (call) {
|
|
485
|
+
pending.delete(id);
|
|
486
|
+
const success = isResultResponse(message);
|
|
487
|
+
const errorMessage = isErrorResponse(message) ? message.error.message : void 0;
|
|
488
|
+
let outputPayload;
|
|
489
|
+
if (isResultResponse(message)) outputPayload = message.result;
|
|
490
|
+
else if (isErrorResponse(message)) outputPayload = message.error;
|
|
491
|
+
const event = {
|
|
492
|
+
toolName: call.toolName,
|
|
493
|
+
sessionId: target.sessionId,
|
|
494
|
+
timestamp: call.startTime,
|
|
495
|
+
durationMs: Date.now() - call.startTime,
|
|
496
|
+
success,
|
|
497
|
+
inputSize: call.inputSize,
|
|
498
|
+
outputSize: byteSize(outputPayload),
|
|
499
|
+
...isErrorResponse(message) && {
|
|
500
|
+
errorMessage,
|
|
501
|
+
errorCode: message.error.code
|
|
502
|
+
},
|
|
503
|
+
...globalMetadata && { metadata: globalMetadata }
|
|
504
|
+
};
|
|
505
|
+
collector.record(event);
|
|
506
|
+
closePendingCall(call, success, errorMessage);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return target.send.call(target, message, options);
|
|
510
|
+
};
|
|
511
|
+
if (prop === "close") return async (...args) => {
|
|
512
|
+
try {
|
|
513
|
+
return await target.close(...args);
|
|
514
|
+
} finally {
|
|
515
|
+
cleanupPendingCalls("Transport closed before tool response");
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
const value = Reflect.get(target, prop, receiver);
|
|
519
|
+
if (typeof value === "function") return value.bind(target);
|
|
520
|
+
return value;
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
if (origOnMessage) proxy.onmessage = origOnMessage;
|
|
524
|
+
if (origOnClose) proxy.onclose = origOnClose;
|
|
525
|
+
return proxy;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Wraps a tool handler function to record metrics.
|
|
529
|
+
* Works with McpServer.tool() callback pattern.
|
|
530
|
+
*/
|
|
531
|
+
function wrapToolHandler(toolName, handler, collector, sampleRate, globalMetadata, tracing) {
|
|
532
|
+
return async (...args) => {
|
|
533
|
+
if (!(Math.random() < sampleRate)) return handler(...args);
|
|
534
|
+
const startTime = Date.now();
|
|
535
|
+
const inputSize = byteSize(args[0]);
|
|
536
|
+
const tracingSpan = tracing ? await startToolSpan(toolName, { "mcp.tool.input_size": inputSize }) : void 0;
|
|
537
|
+
try {
|
|
538
|
+
const result = tracingSpan ? await withSpanContext(tracingSpan, () => handler(...args)) : await handler(...args);
|
|
539
|
+
const event = {
|
|
540
|
+
toolName,
|
|
541
|
+
timestamp: startTime,
|
|
542
|
+
durationMs: Date.now() - startTime,
|
|
543
|
+
success: true,
|
|
544
|
+
inputSize,
|
|
545
|
+
outputSize: byteSize(result),
|
|
546
|
+
...globalMetadata && { metadata: globalMetadata }
|
|
547
|
+
};
|
|
548
|
+
collector.record(event);
|
|
549
|
+
if (tracingSpan) endToolSpan(tracingSpan, true);
|
|
550
|
+
return result;
|
|
551
|
+
} catch (err) {
|
|
552
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
553
|
+
const event = {
|
|
554
|
+
toolName,
|
|
555
|
+
timestamp: startTime,
|
|
556
|
+
durationMs: Date.now() - startTime,
|
|
557
|
+
success: false,
|
|
558
|
+
errorMessage,
|
|
559
|
+
inputSize,
|
|
560
|
+
outputSize: 0,
|
|
561
|
+
...globalMetadata && { metadata: globalMetadata }
|
|
562
|
+
};
|
|
563
|
+
collector.record(event);
|
|
564
|
+
if (tracingSpan) endToolSpan(tracingSpan, false, errorMessage);
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/analytics.ts
|
|
572
|
+
/**
|
|
573
|
+
* MCP Analytics — lightweight observability for MCP servers.
|
|
574
|
+
*
|
|
575
|
+
* @example
|
|
576
|
+
* ```ts
|
|
577
|
+
* const analytics = new McpAnalytics({ exporter: "console" });
|
|
578
|
+
*
|
|
579
|
+
* // Instrument a transport
|
|
580
|
+
* const tracked = analytics.instrument(transport);
|
|
581
|
+
* await server.connect(tracked);
|
|
582
|
+
*
|
|
583
|
+
* // Or wrap individual handlers
|
|
584
|
+
* server.tool("search", schema, analytics.track(handler));
|
|
585
|
+
*
|
|
586
|
+
* // Get stats
|
|
587
|
+
* console.log(analytics.getStats());
|
|
588
|
+
*
|
|
589
|
+
* // Shutdown
|
|
590
|
+
* await analytics.flush();
|
|
591
|
+
* ```
|
|
592
|
+
*/
|
|
593
|
+
var McpAnalytics = class {
|
|
594
|
+
collector;
|
|
595
|
+
sampleRate;
|
|
596
|
+
metadata;
|
|
597
|
+
tracing;
|
|
598
|
+
samplingStrategy;
|
|
599
|
+
constructor(config) {
|
|
600
|
+
this.sampleRate = config.sampleRate ?? 1;
|
|
601
|
+
this.metadata = config.metadata;
|
|
602
|
+
this.tracing = config.tracing ?? false;
|
|
603
|
+
this.samplingStrategy = config.samplingStrategy ?? "per_call";
|
|
604
|
+
const exporter = this.resolveExporter(config);
|
|
605
|
+
this.collector = new Collector(config.maxBufferSize ?? 1e4, exporter, config.flushIntervalMs ?? 5e3, { toolWindowSize: config.toolWindowSize });
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Instrument an MCP transport to automatically track all tool calls.
|
|
609
|
+
* Returns proxy transport that can be used in place of the original.
|
|
610
|
+
*/
|
|
611
|
+
instrument(transport) {
|
|
612
|
+
return instrumentTransport(transport, this.collector, this.sampleRate, this.metadata, this.tracing, this.samplingStrategy);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Wrap a tool handler function to track its execution.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* ```ts
|
|
619
|
+
* server.tool("search", schema, analytics.track(async (params) => {
|
|
620
|
+
* return await doSearch(params);
|
|
621
|
+
* }, "search"));
|
|
622
|
+
* ```
|
|
623
|
+
*/
|
|
624
|
+
track(handler, toolName) {
|
|
625
|
+
return wrapToolHandler(toolName ?? (handler.name || "anonymous"), handler, this.collector, this.sampleRate, this.metadata, this.tracing);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get a snapshot of all analytics data.
|
|
629
|
+
*/
|
|
630
|
+
getStats() {
|
|
631
|
+
return this.collector.getStats();
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Get stats for a specific tool.
|
|
635
|
+
*/
|
|
636
|
+
getToolStats(toolName) {
|
|
637
|
+
return this.collector.getToolStats(toolName);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Get stats for a specific session.
|
|
641
|
+
*/
|
|
642
|
+
getSessionStats(sessionId) {
|
|
643
|
+
return this.collector.getSessionStats(sessionId);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Get top sessions ranked by total call count.
|
|
647
|
+
*/
|
|
648
|
+
getTopSessions(limit = 10) {
|
|
649
|
+
return this.collector.getTopSessions(limit);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Flush all pending events to the exporter.
|
|
653
|
+
*/
|
|
654
|
+
async flush() {
|
|
655
|
+
await this.collector.flush();
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Reset all collected data.
|
|
659
|
+
*/
|
|
660
|
+
reset() {
|
|
661
|
+
this.collector.reset();
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Stop the analytics instance (clears flush timer and flushes remaining events).
|
|
665
|
+
*/
|
|
666
|
+
async shutdown() {
|
|
667
|
+
await this.collector.destroy();
|
|
668
|
+
}
|
|
669
|
+
resolveExporter(config) {
|
|
670
|
+
if (typeof config.exporter === "function") return createCustomExporter(config.exporter);
|
|
671
|
+
switch (config.exporter) {
|
|
672
|
+
case "console": return createConsoleExporter();
|
|
673
|
+
case "json":
|
|
674
|
+
if (!config.json) throw new Error("McpAnalytics: \"json\" exporter requires a \"json\" config with \"path\"");
|
|
675
|
+
return createJsonExporter(config.json);
|
|
676
|
+
case "otlp":
|
|
677
|
+
if (!config.otlp) throw new Error("McpAnalytics: \"otlp\" exporter requires an \"otlp\" config with \"endpoint\"");
|
|
678
|
+
return createOtlpExporter(config.otlp);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
//#endregion
|
|
684
|
+
export { McpAnalytics };
|
|
685
|
+
//# sourceMappingURL=index.js.map
|