toad-eye 2.6.1 → 2.8.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/README.md +80 -402
- package/dist/alerts/conditions.js +31 -6
- package/dist/alerts/grafana.js +6 -1
- package/dist/cli.js +11 -4
- package/dist/core/tracer.js +22 -2
- package/dist/instrumentations/mcp.d.ts +12 -0
- package/dist/instrumentations/mcp.js +85 -0
- package/dist/mcp/client.d.ts +9 -0
- package/dist/mcp/client.js +137 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/metrics.d.ts +5 -1
- package/dist/mcp/metrics.js +36 -5
- package/dist/mcp/middleware.d.ts +11 -1
- package/dist/mcp/middleware.js +54 -9
- package/dist/mcp/spans.d.ts +18 -5
- package/dist/mcp/spans.js +44 -24
- package/dist/mcp/types.d.ts +4 -18
- package/dist/types/metrics.d.ts +1 -0
- package/dist/types/metrics.js +1 -0
- package/dist/types/providers.d.ts +3 -1
- package/package.json +13 -4
- package/templates/alerts.example.yml +21 -0
- package/templates/grafana/dashboards/mcp-analytics.json +295 -0
- package/templates/grafana/dashboards/mcp-e2e.json +323 -0
- package/templates/grafana/dashboards/mcp-server.json +32 -12
- package/templates/otel-collector.yml +2 -2
package/dist/cli.js
CHANGED
|
@@ -97,19 +97,26 @@ function down() {
|
|
|
97
97
|
export function findContainerByService(containers, serviceKey) {
|
|
98
98
|
return containers.find((c) => c.Service === serviceKey);
|
|
99
99
|
}
|
|
100
|
+
function isContainerHealthy(container) {
|
|
101
|
+
return container?.State === "running" && container.ExitCode === 0;
|
|
102
|
+
}
|
|
100
103
|
function status() {
|
|
101
104
|
const composeFile = requireInfra();
|
|
102
105
|
try {
|
|
103
|
-
const output = execFileSync("docker", ["compose", "-f", composeFile, "ps", "--format", "json"], { encoding: "utf-8" });
|
|
106
|
+
const output = execFileSync("docker", ["compose", "-f", composeFile, "ps", "--all", "--format", "json"], { encoding: "utf-8" });
|
|
104
107
|
const lines = output.trim().split("\n").filter(Boolean);
|
|
105
108
|
const containers = lines.map((line) => JSON.parse(line));
|
|
106
109
|
console.log("\u{1f438} toad-eye stack:");
|
|
107
110
|
console.log();
|
|
108
111
|
for (const svc of SERVICES) {
|
|
109
|
-
const container =
|
|
110
|
-
const
|
|
112
|
+
const container = containers.find((c) => c.Service === svc.service);
|
|
113
|
+
const healthy = isContainerHealthy(container);
|
|
114
|
+
const icon = healthy ? "\u{1f7e2}" : "\u{1f534}";
|
|
115
|
+
const suffix = container && !healthy
|
|
116
|
+
? ` (${container.State}${container.ExitCode ? `, exit code ${container.ExitCode}` : ""})`
|
|
117
|
+
: "";
|
|
111
118
|
const loginInfo = "login" in svc ? ` (${svc.login})` : "";
|
|
112
|
-
console.log(` ${
|
|
119
|
+
console.log(` ${icon} ${svc.name.padEnd(16)} ${svc.url}${loginInfo}${suffix}`);
|
|
113
120
|
}
|
|
114
121
|
console.log();
|
|
115
122
|
}
|
package/dist/core/tracer.js
CHANGED
|
@@ -8,6 +8,9 @@ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
|
8
8
|
import { initMetrics, resetMetrics } from "./metrics.js";
|
|
9
9
|
import { resetCustomPricing } from "./pricing.js";
|
|
10
10
|
import { enableAll, disableAll } from "../instrumentations/registry.js";
|
|
11
|
+
import { enableMcpInstrumentation, disableMcpInstrumentation, } from "../instrumentations/mcp.js";
|
|
12
|
+
import { enableMcpClientInstrumentation, disableMcpClientInstrumentation, } from "../mcp/client.js";
|
|
13
|
+
import { resetMcpMetrics } from "../mcp/metrics.js";
|
|
11
14
|
import { BudgetTracker } from "../budget/index.js";
|
|
12
15
|
import { ToadEyeAISpanProcessor } from "../vercel.js";
|
|
13
16
|
const DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
@@ -122,8 +125,22 @@ export function initObservability(config) {
|
|
|
122
125
|
budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
|
|
123
126
|
}
|
|
124
127
|
if (config.instrument?.length) {
|
|
125
|
-
//
|
|
126
|
-
|
|
128
|
+
// Handle MCP server auto-instrumentation
|
|
129
|
+
if (config.instrument.includes("mcp")) {
|
|
130
|
+
const patched = enableMcpInstrumentation();
|
|
131
|
+
if (!patched) {
|
|
132
|
+
console.warn(`toad-eye: "@modelcontextprotocol/sdk" not found — install it to enable MCP server auto-instrumentation`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Handle MCP client auto-instrumentation
|
|
136
|
+
if (config.instrument.includes("mcp-client")) {
|
|
137
|
+
const patched = enableMcpClientInstrumentation();
|
|
138
|
+
if (!patched) {
|
|
139
|
+
console.warn(`toad-eye: "@modelcontextprotocol/sdk" not found — install it to enable MCP client auto-instrumentation`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Filter out special targets — they use separate mechanisms
|
|
143
|
+
const patchProviders = config.instrument.filter((i) => i !== "ai" && i !== "mcp" && i !== "mcp-client");
|
|
127
144
|
if (patchProviders.length > 0) {
|
|
128
145
|
// enableAll is async (lazy-loads provider modules on first call).
|
|
129
146
|
// Fire-and-forget: patching completes before any async SDK call.
|
|
@@ -137,10 +154,13 @@ export async function shutdown() {
|
|
|
137
154
|
if (!sdk)
|
|
138
155
|
return;
|
|
139
156
|
disableAll();
|
|
157
|
+
disableMcpInstrumentation();
|
|
158
|
+
disableMcpClientInstrumentation();
|
|
140
159
|
await sdk.shutdown();
|
|
141
160
|
sdk = null;
|
|
142
161
|
currentConfig = null;
|
|
143
162
|
budgetTracker = null;
|
|
144
163
|
resetMetrics();
|
|
164
|
+
resetMcpMetrics();
|
|
145
165
|
resetCustomPricing();
|
|
146
166
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-instrumentation for MCP SDK.
|
|
3
|
+
*
|
|
4
|
+
* Patches McpServer.prototype so that any server instance created after
|
|
5
|
+
* enableMcpInstrumentation() is automatically instrumented — no need
|
|
6
|
+
* to call toadEyeMiddleware(server) manually.
|
|
7
|
+
*
|
|
8
|
+
* Uses __toad_eye_mcp_patched flag to prevent double-instrumentation
|
|
9
|
+
* when both auto and manual approaches are used.
|
|
10
|
+
*/
|
|
11
|
+
export declare function enableMcpInstrumentation(): boolean;
|
|
12
|
+
export declare function disableMcpInstrumentation(): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-instrumentation for MCP SDK.
|
|
3
|
+
*
|
|
4
|
+
* Patches McpServer.prototype so that any server instance created after
|
|
5
|
+
* enableMcpInstrumentation() is automatically instrumented — no need
|
|
6
|
+
* to call toadEyeMiddleware(server) manually.
|
|
7
|
+
*
|
|
8
|
+
* Uses __toad_eye_mcp_patched flag to prevent double-instrumentation
|
|
9
|
+
* when both auto and manual approaches are used.
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { toadEyeMiddleware } from "../mcp/middleware.js";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const PATCHED_FLAG = "__toad_eye_mcp_patched";
|
|
15
|
+
const MODULE_NAME = "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
let mcpServerProto = null;
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
let originals = null;
|
|
20
|
+
export function enableMcpInstrumentation() {
|
|
21
|
+
try {
|
|
22
|
+
require.resolve(MODULE_NAME);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
|
+
const sdk = require(MODULE_NAME);
|
|
29
|
+
const McpServer = sdk.McpServer ?? sdk.default?.McpServer;
|
|
30
|
+
if (!McpServer?.prototype)
|
|
31
|
+
return false;
|
|
32
|
+
if (McpServer.prototype[PATCHED_FLAG])
|
|
33
|
+
return true;
|
|
34
|
+
const proto = McpServer.prototype;
|
|
35
|
+
mcpServerProto = proto;
|
|
36
|
+
originals = {
|
|
37
|
+
tool: proto.tool,
|
|
38
|
+
resource: proto.resource,
|
|
39
|
+
prompt: proto.prompt,
|
|
40
|
+
};
|
|
41
|
+
// WeakSet tracks which instances have been instrumented
|
|
42
|
+
const instrumented = new WeakSet();
|
|
43
|
+
function ensureInstrumented(instance) {
|
|
44
|
+
if (instrumented.has(instance))
|
|
45
|
+
return;
|
|
46
|
+
instrumented.add(instance);
|
|
47
|
+
// Restore original methods so middleware wraps the real ones
|
|
48
|
+
instance.tool = originals.tool.bind(instance);
|
|
49
|
+
if (originals.resource)
|
|
50
|
+
instance.resource = originals.resource.bind(instance);
|
|
51
|
+
if (originals.prompt)
|
|
52
|
+
instance.prompt = originals.prompt.bind(instance);
|
|
53
|
+
toadEyeMiddleware(instance);
|
|
54
|
+
}
|
|
55
|
+
proto.tool = function patchedTool(...args) {
|
|
56
|
+
ensureInstrumented(this);
|
|
57
|
+
return this.tool(...args);
|
|
58
|
+
};
|
|
59
|
+
if (originals.resource) {
|
|
60
|
+
proto.resource = function patchedResource(...args) {
|
|
61
|
+
ensureInstrumented(this);
|
|
62
|
+
return this.resource(...args);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (originals.prompt) {
|
|
66
|
+
proto.prompt = function patchedPrompt(...args) {
|
|
67
|
+
ensureInstrumented(this);
|
|
68
|
+
return this.prompt(...args);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
proto[PATCHED_FLAG] = true;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
export function disableMcpInstrumentation() {
|
|
75
|
+
if (!mcpServerProto || !originals)
|
|
76
|
+
return;
|
|
77
|
+
mcpServerProto.tool = originals.tool;
|
|
78
|
+
if (originals.resource)
|
|
79
|
+
mcpServerProto.resource = originals.resource;
|
|
80
|
+
if (originals.prompt)
|
|
81
|
+
mcpServerProto.prompt = originals.prompt;
|
|
82
|
+
delete mcpServerProto[PATCHED_FLAG];
|
|
83
|
+
mcpServerProto = null;
|
|
84
|
+
originals = null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client instrumentation — patches Client.prototype to create
|
|
3
|
+
* SpanKind.CLIENT spans for outgoing tool calls, resource reads, and prompt gets.
|
|
4
|
+
*
|
|
5
|
+
* Injects W3C traceparent into _meta field so server-side middleware
|
|
6
|
+
* can link client and server spans into one distributed trace.
|
|
7
|
+
*/
|
|
8
|
+
export declare function enableMcpClientInstrumentation(): boolean;
|
|
9
|
+
export declare function disableMcpClientInstrumentation(): void;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client instrumentation — patches Client.prototype to create
|
|
3
|
+
* SpanKind.CLIENT spans for outgoing tool calls, resource reads, and prompt gets.
|
|
4
|
+
*
|
|
5
|
+
* Injects W3C traceparent into _meta field so server-side middleware
|
|
6
|
+
* can link client and server spans into one distributed trace.
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { trace, context, propagation, SpanKind } from "@opentelemetry/api";
|
|
10
|
+
import { MCP_METHODS, endSpanSuccess, endSpanError } from "./spans.js";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const TRACER_NAME = "toad-eye-mcp-client";
|
|
13
|
+
const PATCHED_FLAG = "__toad_eye_mcp_client_patched";
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
let clientProto = null;
|
|
16
|
+
let originals = null;
|
|
17
|
+
/**
|
|
18
|
+
* Inject W3C traceparent into MCP _meta field.
|
|
19
|
+
* This allows server-side middleware to extract parent context.
|
|
20
|
+
*/
|
|
21
|
+
function injectTraceContext(params) {
|
|
22
|
+
const meta = params._meta ?? {};
|
|
23
|
+
const carrier = {};
|
|
24
|
+
propagation.inject(context.active(), carrier);
|
|
25
|
+
if (carrier["traceparent"]) {
|
|
26
|
+
return {
|
|
27
|
+
...params,
|
|
28
|
+
_meta: {
|
|
29
|
+
...meta,
|
|
30
|
+
traceparent: carrier["traceparent"],
|
|
31
|
+
...(carrier["tracestate"] ? { tracestate: carrier["tracestate"] } : {}),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return params;
|
|
36
|
+
}
|
|
37
|
+
export function enableMcpClientInstrumentation() {
|
|
38
|
+
try {
|
|
39
|
+
require.resolve("@modelcontextprotocol/sdk/client/index.js");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
const sdk = require("@modelcontextprotocol/sdk/client/index.js");
|
|
46
|
+
const Client = sdk.Client ?? sdk.default?.Client;
|
|
47
|
+
if (!Client?.prototype)
|
|
48
|
+
return false;
|
|
49
|
+
if (Client.prototype[PATCHED_FLAG])
|
|
50
|
+
return true;
|
|
51
|
+
const proto = Client.prototype;
|
|
52
|
+
clientProto = proto;
|
|
53
|
+
originals = {
|
|
54
|
+
callTool: proto.callTool,
|
|
55
|
+
readResource: proto.readResource,
|
|
56
|
+
getPrompt: proto.getPrompt,
|
|
57
|
+
};
|
|
58
|
+
const tracer = trace.getTracer(TRACER_NAME);
|
|
59
|
+
// --- Patch callTool ---
|
|
60
|
+
proto.callTool = async function patchedCallTool(params, ...rest) {
|
|
61
|
+
const toolName = params.name ?? "unknown";
|
|
62
|
+
const span = tracer.startSpan(`${MCP_METHODS.TOOLS_CALL} ${toolName}`, {
|
|
63
|
+
kind: SpanKind.CLIENT,
|
|
64
|
+
attributes: {
|
|
65
|
+
"gen_ai.operation.name": MCP_METHODS.TOOLS_CALL,
|
|
66
|
+
"mcp.method.name": MCP_METHODS.TOOLS_CALL,
|
|
67
|
+
"gen_ai.tool.name": toolName,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const enrichedParams = injectTraceContext(params);
|
|
71
|
+
try {
|
|
72
|
+
const result = await context.with(trace.setSpan(context.active(), span), () => originals.callTool.call(this, enrichedParams, ...rest));
|
|
73
|
+
endSpanSuccess(span);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
endSpanError(span, error);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
// --- Patch readResource ---
|
|
82
|
+
proto.readResource = async function patchedReadResource(params, ...rest) {
|
|
83
|
+
const uri = params.uri ?? "unknown";
|
|
84
|
+
const span = tracer.startSpan(`${MCP_METHODS.RESOURCES_READ} ${uri}`, {
|
|
85
|
+
kind: SpanKind.CLIENT,
|
|
86
|
+
attributes: {
|
|
87
|
+
"gen_ai.operation.name": MCP_METHODS.RESOURCES_READ,
|
|
88
|
+
"mcp.method.name": MCP_METHODS.RESOURCES_READ,
|
|
89
|
+
"gen_ai.data_source.id": uri,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const enrichedParams = injectTraceContext(params);
|
|
93
|
+
try {
|
|
94
|
+
const result = await context.with(trace.setSpan(context.active(), span), () => originals.readResource.call(this, enrichedParams, ...rest));
|
|
95
|
+
endSpanSuccess(span);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
endSpanError(span, error);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// --- Patch getPrompt ---
|
|
104
|
+
proto.getPrompt = async function patchedGetPrompt(params, ...rest) {
|
|
105
|
+
const promptName = params.name ?? "unknown";
|
|
106
|
+
const span = tracer.startSpan(`${MCP_METHODS.PROMPTS_GET} ${promptName}`, {
|
|
107
|
+
kind: SpanKind.CLIENT,
|
|
108
|
+
attributes: {
|
|
109
|
+
"gen_ai.operation.name": MCP_METHODS.PROMPTS_GET,
|
|
110
|
+
"mcp.method.name": MCP_METHODS.PROMPTS_GET,
|
|
111
|
+
"gen_ai.prompt.name": promptName,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const enrichedParams = injectTraceContext(params);
|
|
115
|
+
try {
|
|
116
|
+
const result = await context.with(trace.setSpan(context.active(), span), () => originals.getPrompt.call(this, enrichedParams, ...rest));
|
|
117
|
+
endSpanSuccess(span);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
endSpanError(span, error);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
proto[PATCHED_FLAG] = true;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
export function disableMcpClientInstrumentation() {
|
|
129
|
+
if (!clientProto || !originals)
|
|
130
|
+
return;
|
|
131
|
+
clientProto.callTool = originals.callTool;
|
|
132
|
+
clientProto.readResource = originals.readResource;
|
|
133
|
+
clientProto.getPrompt = originals.getPrompt;
|
|
134
|
+
delete clientProto[PATCHED_FLAG];
|
|
135
|
+
clientProto = null;
|
|
136
|
+
originals = null;
|
|
137
|
+
}
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { toadEyeMiddleware, traceSampling } from "./middleware.js";
|
|
2
|
-
export type { TraceSamplingOptions } from "./middleware.js";
|
|
3
|
-
export type { ToadMcpOptions
|
|
2
|
+
export type { TraceSamplingOptions, ToadEyeMiddlewareDispose, } from "./middleware.js";
|
|
3
|
+
export type { ToadMcpOptions } from "./types.js";
|
|
4
|
+
export { MCP_METHODS } from "./spans.js";
|
|
5
|
+
export { enableMcpClientInstrumentation, disableMcpClientInstrumentation, } from "./client.js";
|
package/dist/mcp/index.js
CHANGED
package/dist/mcp/metrics.d.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Separate from core metrics (core/metrics.ts) — these only exist
|
|
5
5
|
* when MCP middleware is active. Initialized on first middleware call.
|
|
6
|
+
*
|
|
7
|
+
* All metrics include `mcp.method.name` dimension per OTel MCP semconv.
|
|
6
8
|
*/
|
|
7
|
-
export declare function recordMcpToolCall(toolName: string, durationMs: number, status: "success" | "error"): void;
|
|
9
|
+
export declare function recordMcpToolCall(toolName: string, durationMs: number, status: "success" | "error", sessionId?: string): void;
|
|
8
10
|
export declare function recordMcpToolError(toolName: string, errorType: string): void;
|
|
9
11
|
export declare function recordMcpResourceRead(uri: string): void;
|
|
10
12
|
export declare function recordMcpSessionStart(): void;
|
|
11
13
|
export declare function recordMcpSessionEnd(): void;
|
|
14
|
+
/** Reset MCP metrics state — must be called from shutdown() alongside resetMetrics(). */
|
|
15
|
+
export declare function resetMcpMetrics(): void;
|
package/dist/mcp/metrics.js
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Separate from core metrics (core/metrics.ts) — these only exist
|
|
5
5
|
* when MCP middleware is active. Initialized on first middleware call.
|
|
6
|
+
*
|
|
7
|
+
* All metrics include `mcp.method.name` dimension per OTel MCP semconv.
|
|
6
8
|
*/
|
|
7
9
|
import { metrics, } from "@opentelemetry/api";
|
|
8
10
|
import { GEN_AI_METRICS } from "../types/metrics.js";
|
|
11
|
+
import { MCP_METHODS } from "./spans.js";
|
|
9
12
|
const METER_NAME = "toad-eye-mcp";
|
|
10
13
|
let toolDuration;
|
|
11
14
|
let toolCalls;
|
|
12
15
|
let toolErrors;
|
|
13
16
|
let resourceReads;
|
|
14
17
|
let sessionActive;
|
|
18
|
+
let toolCallers;
|
|
15
19
|
let initialized = false;
|
|
16
20
|
function ensureInit() {
|
|
17
21
|
if (initialized)
|
|
@@ -34,19 +38,42 @@ function ensureInit() {
|
|
|
34
38
|
sessionActive = meter.createUpDownCounter(GEN_AI_METRICS.MCP_SESSION_ACTIVE, {
|
|
35
39
|
description: "Number of active MCP sessions",
|
|
36
40
|
});
|
|
41
|
+
toolCallers = meter.createCounter(GEN_AI_METRICS.MCP_TOOL_CALLERS, {
|
|
42
|
+
description: "MCP tool calls by session — use count(distinct) for unique callers",
|
|
43
|
+
});
|
|
37
44
|
}
|
|
38
|
-
export function recordMcpToolCall(toolName, durationMs, status) {
|
|
45
|
+
export function recordMcpToolCall(toolName, durationMs, status, sessionId) {
|
|
39
46
|
ensureInit();
|
|
40
|
-
toolCalls.add(1, {
|
|
41
|
-
|
|
47
|
+
toolCalls.add(1, {
|
|
48
|
+
"gen_ai.tool.name": toolName,
|
|
49
|
+
"mcp.method.name": MCP_METHODS.TOOLS_CALL,
|
|
50
|
+
status,
|
|
51
|
+
});
|
|
52
|
+
toolDuration.record(durationMs, {
|
|
53
|
+
"gen_ai.tool.name": toolName,
|
|
54
|
+
"mcp.method.name": MCP_METHODS.TOOLS_CALL,
|
|
55
|
+
});
|
|
56
|
+
if (sessionId) {
|
|
57
|
+
toolCallers.add(1, {
|
|
58
|
+
"gen_ai.tool.name": toolName,
|
|
59
|
+
"mcp.session.id": sessionId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
42
62
|
}
|
|
43
63
|
export function recordMcpToolError(toolName, errorType) {
|
|
44
64
|
ensureInit();
|
|
45
|
-
toolErrors.add(1, {
|
|
65
|
+
toolErrors.add(1, {
|
|
66
|
+
"gen_ai.tool.name": toolName,
|
|
67
|
+
"mcp.method.name": MCP_METHODS.TOOLS_CALL,
|
|
68
|
+
"error.type": errorType,
|
|
69
|
+
});
|
|
46
70
|
}
|
|
47
71
|
export function recordMcpResourceRead(uri) {
|
|
48
72
|
ensureInit();
|
|
49
|
-
resourceReads.add(1, {
|
|
73
|
+
resourceReads.add(1, {
|
|
74
|
+
"gen_ai.data_source.id": uri,
|
|
75
|
+
"mcp.method.name": MCP_METHODS.RESOURCES_READ,
|
|
76
|
+
});
|
|
50
77
|
}
|
|
51
78
|
export function recordMcpSessionStart() {
|
|
52
79
|
ensureInit();
|
|
@@ -56,3 +83,7 @@ export function recordMcpSessionEnd() {
|
|
|
56
83
|
ensureInit();
|
|
57
84
|
sessionActive.add(-1);
|
|
58
85
|
}
|
|
86
|
+
/** Reset MCP metrics state — must be called from shutdown() alongside resetMetrics(). */
|
|
87
|
+
export function resetMcpMetrics() {
|
|
88
|
+
initialized = false;
|
|
89
|
+
}
|
package/dist/mcp/middleware.d.ts
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* and strict TypeScript. Wrapping the public API is the stable path.
|
|
10
10
|
*/
|
|
11
11
|
import type { ToadMcpOptions } from "./types.js";
|
|
12
|
-
export
|
|
12
|
+
export interface ToadEyeMiddlewareDispose {
|
|
13
|
+
/** Call when the MCP server is shutting down to decrement the active session counter. */
|
|
14
|
+
(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function toadEyeMiddleware(server: any, options?: ToadMcpOptions): ToadEyeMiddlewareDispose;
|
|
13
17
|
/**
|
|
14
18
|
* Trace a sampling/createMessage call (server → client LLM request).
|
|
15
19
|
*
|
|
@@ -45,4 +49,10 @@ export interface TraceSamplingOptions {
|
|
|
45
49
|
readonly serverVersion?: string | undefined;
|
|
46
50
|
/** Parent OTel context. */
|
|
47
51
|
readonly parentContext?: import("@opentelemetry/api").Context | undefined;
|
|
52
|
+
/** MCP session ID. */
|
|
53
|
+
readonly sessionId?: string | undefined;
|
|
54
|
+
/** MCP protocol version. */
|
|
55
|
+
readonly protocolVersion?: string | undefined;
|
|
56
|
+
/** Network transport: "pipe" (stdio) or "tcp" (HTTP). */
|
|
57
|
+
readonly networkTransport?: string | undefined;
|
|
48
58
|
}
|
package/dist/mcp/middleware.js
CHANGED
|
@@ -8,11 +8,26 @@
|
|
|
8
8
|
* Why not monkey-patching: MCP SDK uses private class members (_requestHandlers)
|
|
9
9
|
* and strict TypeScript. Wrapping the public API is the stable path.
|
|
10
10
|
*/
|
|
11
|
-
import { context, diag, DiagConsoleLogger, DiagLogLevel, } from "@opentelemetry/api";
|
|
11
|
+
import { context, trace, diag, DiagConsoleLogger, DiagLogLevel, } from "@opentelemetry/api";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
12
13
|
import { extractContextFromMeta } from "./context.js";
|
|
13
14
|
import { startToolSpan, startResourceSpan, startPromptSpan, startSamplingSpan, endSpanSuccess, endSpanError, } from "./spans.js";
|
|
14
|
-
import { recordMcpToolCall, recordMcpToolError, recordMcpResourceRead, recordMcpSessionStart, } from "./metrics.js";
|
|
15
|
+
import { recordMcpToolCall, recordMcpToolError, recordMcpResourceRead, recordMcpSessionStart, recordMcpSessionEnd, } from "./metrics.js";
|
|
15
16
|
const DEFAULT_MAX_PAYLOAD_SIZE = 4096;
|
|
17
|
+
/** Detect MCP transport type from server internals. */
|
|
18
|
+
function detectTransport(server) {
|
|
19
|
+
// Check if server has a transport reference
|
|
20
|
+
const transport = server.transport ?? server._transport;
|
|
21
|
+
if (transport && typeof transport === "object") {
|
|
22
|
+
const name = transport.constructor?.name ?? "";
|
|
23
|
+
if (/stdio/i.test(name))
|
|
24
|
+
return "pipe";
|
|
25
|
+
if (/sse|http/i.test(name))
|
|
26
|
+
return "tcp";
|
|
27
|
+
}
|
|
28
|
+
// Default: stdio is most common for local dev
|
|
29
|
+
return "pipe";
|
|
30
|
+
}
|
|
16
31
|
function truncate(value, maxBytes) {
|
|
17
32
|
if (value.length <= maxBytes)
|
|
18
33
|
return value;
|
|
@@ -23,7 +38,15 @@ function redactObject(obj, keys) {
|
|
|
23
38
|
return {};
|
|
24
39
|
const result = {};
|
|
25
40
|
for (const [k, v] of Object.entries(obj)) {
|
|
26
|
-
|
|
41
|
+
if (keys.includes(k)) {
|
|
42
|
+
result[k] = "[REDACTED]";
|
|
43
|
+
}
|
|
44
|
+
else if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
45
|
+
result[k] = redactObject(v, keys);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
result[k] = v;
|
|
49
|
+
}
|
|
27
50
|
}
|
|
28
51
|
return result;
|
|
29
52
|
}
|
|
@@ -75,7 +98,19 @@ server, options = {}) {
|
|
|
75
98
|
const redactKeys = options.redactKeys ?? [];
|
|
76
99
|
const maxPayload = options.maxPayloadSize ?? DEFAULT_MAX_PAYLOAD_SIZE;
|
|
77
100
|
const propagate = options.propagateContext ?? true;
|
|
78
|
-
|
|
101
|
+
// Session ID: explicit > server-provided > generated
|
|
102
|
+
const sessionId = options.sessionId ?? server.sessionId ?? randomUUID().slice(0, 8);
|
|
103
|
+
// Protocol version from server capabilities or default
|
|
104
|
+
const protocolVersion = server.protocolVersion ?? server._protocolVersion ?? "2025-03-26";
|
|
105
|
+
// Detect transport type: pipe (stdio) vs tcp (HTTP)
|
|
106
|
+
const networkTransport = detectTransport(server);
|
|
107
|
+
const spanOpts = {
|
|
108
|
+
serverName,
|
|
109
|
+
serverVersion,
|
|
110
|
+
sessionId,
|
|
111
|
+
protocolVersion,
|
|
112
|
+
networkTransport,
|
|
113
|
+
};
|
|
79
114
|
// --- Wrap .tool() ---
|
|
80
115
|
const originalTool = server.tool.bind(server);
|
|
81
116
|
server.tool = function wrappedTool(name, ...rest) {
|
|
@@ -110,7 +145,7 @@ server, options = {}) {
|
|
|
110
145
|
}
|
|
111
146
|
const start = performance.now();
|
|
112
147
|
try {
|
|
113
|
-
const result = await context.with(
|
|
148
|
+
const result = await context.with(trace.setSpan(parentCtx, span), () => originalHandler(...handlerArgs));
|
|
114
149
|
const durationMs = performance.now() - start;
|
|
115
150
|
// Record outputs if enabled
|
|
116
151
|
if (recordOutputs && result) {
|
|
@@ -118,14 +153,14 @@ server, options = {}) {
|
|
|
118
153
|
span.setAttribute("gen_ai.tool.call.result", serialized);
|
|
119
154
|
}
|
|
120
155
|
endSpanSuccess(span);
|
|
121
|
-
recordMcpToolCall(name, durationMs, "success");
|
|
156
|
+
recordMcpToolCall(name, durationMs, "success", sessionId);
|
|
122
157
|
return result;
|
|
123
158
|
}
|
|
124
159
|
catch (error) {
|
|
125
160
|
const durationMs = performance.now() - start;
|
|
126
161
|
const errorType = error instanceof Error ? error.constructor.name : "UnknownError";
|
|
127
162
|
endSpanError(span, error);
|
|
128
|
-
recordMcpToolCall(name, durationMs, "error");
|
|
163
|
+
recordMcpToolCall(name, durationMs, "error", sessionId);
|
|
129
164
|
recordMcpToolError(name, errorType);
|
|
130
165
|
throw error;
|
|
131
166
|
}
|
|
@@ -154,7 +189,7 @@ server, options = {}) {
|
|
|
154
189
|
options.onResourceRead(span, uri);
|
|
155
190
|
}
|
|
156
191
|
try {
|
|
157
|
-
const result = await originalHandler(...handlerArgs);
|
|
192
|
+
const result = await context.with(trace.setSpan(parentCtx, span), () => originalHandler(...handlerArgs));
|
|
158
193
|
endSpanSuccess(span);
|
|
159
194
|
recordMcpResourceRead(uri);
|
|
160
195
|
return result;
|
|
@@ -185,7 +220,7 @@ server, options = {}) {
|
|
|
185
220
|
parentContext: parentCtx,
|
|
186
221
|
});
|
|
187
222
|
try {
|
|
188
|
-
const result = await originalHandler(...handlerArgs);
|
|
223
|
+
const result = await context.with(trace.setSpan(parentCtx, span), () => originalHandler(...handlerArgs));
|
|
189
224
|
endSpanSuccess(span);
|
|
190
225
|
return result;
|
|
191
226
|
}
|
|
@@ -197,6 +232,13 @@ server, options = {}) {
|
|
|
197
232
|
return originalPrompt(name, ...rest);
|
|
198
233
|
};
|
|
199
234
|
}
|
|
235
|
+
let disposed = false;
|
|
236
|
+
return () => {
|
|
237
|
+
if (disposed)
|
|
238
|
+
return;
|
|
239
|
+
disposed = true;
|
|
240
|
+
recordMcpSessionEnd();
|
|
241
|
+
};
|
|
200
242
|
}
|
|
201
243
|
/**
|
|
202
244
|
* Trace a sampling/createMessage call (server → client LLM request).
|
|
@@ -227,6 +269,9 @@ export async function traceSampling(fn, options) {
|
|
|
227
269
|
serverName: options.serverName ?? "mcp-server",
|
|
228
270
|
serverVersion: options.serverVersion ?? "unknown",
|
|
229
271
|
parentContext: options.parentContext,
|
|
272
|
+
sessionId: options.sessionId,
|
|
273
|
+
protocolVersion: options.protocolVersion,
|
|
274
|
+
networkTransport: options.networkTransport,
|
|
230
275
|
});
|
|
231
276
|
if (options.maxTokens !== undefined) {
|
|
232
277
|
span.setAttribute("gen_ai.request.max_tokens", options.maxTokens);
|
package/dist/mcp/spans.d.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OTel span creation for MCP operations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - tools/call →
|
|
6
|
-
* - resources/read →
|
|
7
|
-
* - prompts/get →
|
|
8
|
-
* - sampling/createMessage →
|
|
4
|
+
* Follows OTel MCP semantic conventions (PR #2083):
|
|
5
|
+
* - tools/call → `tools/call {tool_name}`
|
|
6
|
+
* - resources/read → `resources/read {uri}`
|
|
7
|
+
* - prompts/get → `prompts/get {name}`
|
|
8
|
+
* - sampling/createMessage → `sampling/createMessage {model}`
|
|
9
9
|
*/
|
|
10
10
|
import { type Context } from "@opentelemetry/api";
|
|
11
11
|
declare const tracer: import("@opentelemetry/api").Tracer;
|
|
12
|
+
/** MCP method names per OTel semconv. */
|
|
13
|
+
export declare const MCP_METHODS: {
|
|
14
|
+
readonly TOOLS_CALL: "tools/call";
|
|
15
|
+
readonly RESOURCES_READ: "resources/read";
|
|
16
|
+
readonly PROMPTS_GET: "prompts/get";
|
|
17
|
+
readonly SAMPLING_CREATE_MESSAGE: "sampling/createMessage";
|
|
18
|
+
};
|
|
12
19
|
export interface SpanOptions {
|
|
13
20
|
readonly serverName: string;
|
|
14
21
|
readonly serverVersion: string;
|
|
15
22
|
readonly parentContext: Context;
|
|
23
|
+
readonly sessionId?: string | undefined;
|
|
24
|
+
readonly protocolVersion?: string | undefined;
|
|
25
|
+
readonly networkTransport?: string | undefined;
|
|
16
26
|
}
|
|
17
27
|
export declare function startToolSpan(toolName: string, options: SpanOptions): import("@opentelemetry/api").Span;
|
|
18
28
|
export declare function startResourceSpan(uri: string, options: SpanOptions): import("@opentelemetry/api").Span;
|
|
@@ -21,6 +31,9 @@ export interface SamplingSpanOptions {
|
|
|
21
31
|
readonly serverName: string;
|
|
22
32
|
readonly serverVersion: string;
|
|
23
33
|
readonly parentContext?: Context | undefined;
|
|
34
|
+
readonly sessionId?: string | undefined;
|
|
35
|
+
readonly protocolVersion?: string | undefined;
|
|
36
|
+
readonly networkTransport?: string | undefined;
|
|
24
37
|
}
|
|
25
38
|
export declare function startSamplingSpan(model: string, options: SamplingSpanOptions): import("@opentelemetry/api").Span;
|
|
26
39
|
export declare function endSpanSuccess(span: ReturnType<typeof tracer.startSpan>): void;
|