toad-eye 2.7.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/README.md +80 -408
- package/dist/alerts/conditions.js +31 -6
- package/dist/alerts/grafana.js +6 -1
- package/dist/core/span-end-processor.d.ts +30 -0
- package/dist/core/span-end-processor.js +78 -0
- package/dist/core/tracer.js +35 -5
- package/dist/index.d.ts +1 -0
- 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 +3 -2
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/metrics.d.ts +3 -1
- package/dist/mcp/metrics.js +15 -1
- package/dist/mcp/middleware.d.ts +5 -1
- package/dist/mcp/middleware.js +23 -8
- package/dist/mcp/spans.js +3 -3
- package/dist/mcp/types.d.ts +2 -21
- package/dist/types/config.d.ts +3 -0
- package/dist/types/index.d.ts +1 -0
- 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 +1 -1
- 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 +0 -19
package/dist/alerts/grafana.js
CHANGED
|
@@ -2,8 +2,13 @@ export async function postGrafanaAnnotation(grafanaUrl, alert, apiKey) {
|
|
|
2
2
|
const headers = {
|
|
3
3
|
"Content-Type": "application/json",
|
|
4
4
|
};
|
|
5
|
-
if (apiKey)
|
|
5
|
+
if (apiKey) {
|
|
6
6
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
// Fall back to Basic Auth with default Grafana credentials
|
|
10
|
+
headers["Authorization"] = `Basic ${btoa("admin:admin")}`;
|
|
11
|
+
}
|
|
7
12
|
const res = await fetch(`${grafanaUrl}/api/annotations`, {
|
|
8
13
|
method: "POST",
|
|
9
14
|
headers,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpanProcessor that calls user's onSpanEnd callback with structured span data.
|
|
3
|
+
*
|
|
4
|
+
* Extracts LLM/MCP-relevant attributes from ReadableSpan into a clean
|
|
5
|
+
* SpanEndData object — user never touches OTel SDK internals.
|
|
6
|
+
*/
|
|
7
|
+
import type { SpanProcessor, ReadableSpan } from "@opentelemetry/sdk-trace-base";
|
|
8
|
+
/** Structured span data passed to the onSpanEnd callback. */
|
|
9
|
+
export interface SpanEndData {
|
|
10
|
+
readonly traceId: string;
|
|
11
|
+
readonly spanId: string;
|
|
12
|
+
readonly parentSpanId: string | undefined;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly kind: "client" | "server" | "internal" | "producer" | "consumer";
|
|
15
|
+
readonly status: "ok" | "error" | "unset";
|
|
16
|
+
readonly durationMs: number;
|
|
17
|
+
readonly startTime: Date;
|
|
18
|
+
readonly endTime: Date;
|
|
19
|
+
readonly attributes: Readonly<Record<string, string | number | boolean>>;
|
|
20
|
+
readonly error: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
export type OnSpanEndCallback = (data: SpanEndData) => void | Promise<void>;
|
|
23
|
+
export declare class ToadEyeSpanEndProcessor implements SpanProcessor {
|
|
24
|
+
private readonly callback;
|
|
25
|
+
constructor(callback: OnSpanEndCallback);
|
|
26
|
+
onStart(): void;
|
|
27
|
+
onEnd(span: ReadableSpan): void;
|
|
28
|
+
forceFlush(): Promise<void>;
|
|
29
|
+
shutdown(): Promise<void>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpanProcessor that calls user's onSpanEnd callback with structured span data.
|
|
3
|
+
*
|
|
4
|
+
* Extracts LLM/MCP-relevant attributes from ReadableSpan into a clean
|
|
5
|
+
* SpanEndData object — user never touches OTel SDK internals.
|
|
6
|
+
*/
|
|
7
|
+
import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
|
|
8
|
+
const SPAN_KIND_MAP = {
|
|
9
|
+
[SpanKind.CLIENT]: "client",
|
|
10
|
+
[SpanKind.SERVER]: "server",
|
|
11
|
+
[SpanKind.INTERNAL]: "internal",
|
|
12
|
+
[SpanKind.PRODUCER]: "producer",
|
|
13
|
+
[SpanKind.CONSUMER]: "consumer",
|
|
14
|
+
};
|
|
15
|
+
function hrtimeToDate(hrtime) {
|
|
16
|
+
return new Date(hrtime[0] * 1000 + hrtime[1] / 1_000_000);
|
|
17
|
+
}
|
|
18
|
+
function hrtimeDurationMs(start, end) {
|
|
19
|
+
return (end[0] - start[0]) * 1000 + (end[1] - start[1]) / 1_000_000;
|
|
20
|
+
}
|
|
21
|
+
function flattenAttributes(attrs) {
|
|
22
|
+
const result = {};
|
|
23
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
24
|
+
if (typeof value === "string" ||
|
|
25
|
+
typeof value === "number" ||
|
|
26
|
+
typeof value === "boolean") {
|
|
27
|
+
result[key] = value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function toSpanEndData(span) {
|
|
33
|
+
const ctx = span.spanContext();
|
|
34
|
+
return {
|
|
35
|
+
traceId: ctx.traceId,
|
|
36
|
+
spanId: ctx.spanId,
|
|
37
|
+
parentSpanId: span.parentSpanId || undefined,
|
|
38
|
+
name: span.name,
|
|
39
|
+
kind: SPAN_KIND_MAP[span.kind] ?? "internal",
|
|
40
|
+
status: span.status.code === SpanStatusCode.ERROR
|
|
41
|
+
? "error"
|
|
42
|
+
: span.status.code === SpanStatusCode.OK
|
|
43
|
+
? "ok"
|
|
44
|
+
: "unset",
|
|
45
|
+
durationMs: hrtimeDurationMs(span.startTime, span.endTime),
|
|
46
|
+
startTime: hrtimeToDate(span.startTime),
|
|
47
|
+
endTime: hrtimeToDate(span.endTime),
|
|
48
|
+
attributes: flattenAttributes(span.attributes),
|
|
49
|
+
error: span.status.message || undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export class ToadEyeSpanEndProcessor {
|
|
53
|
+
callback;
|
|
54
|
+
constructor(callback) {
|
|
55
|
+
this.callback = callback;
|
|
56
|
+
}
|
|
57
|
+
onStart() { }
|
|
58
|
+
onEnd(span) {
|
|
59
|
+
try {
|
|
60
|
+
const result = this.callback(toSpanEndData(span));
|
|
61
|
+
// If callback returns a promise, don't block — fire and forget
|
|
62
|
+
if (result instanceof Promise) {
|
|
63
|
+
result.catch((err) => {
|
|
64
|
+
console.warn(`toad-eye: onSpanEnd callback failed: ${err instanceof Error ? err.message : err}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.warn(`toad-eye: onSpanEnd callback failed: ${err instanceof Error ? err.message : err}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
forceFlush() {
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
}
|
|
75
|
+
shutdown() {
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/core/tracer.js
CHANGED
|
@@ -8,8 +8,12 @@ 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";
|
|
16
|
+
import { ToadEyeSpanEndProcessor } from "./span-end-processor.js";
|
|
13
17
|
const DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
14
18
|
const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
|
|
15
19
|
let sdk = null;
|
|
@@ -78,11 +82,20 @@ export function initObservability(config) {
|
|
|
78
82
|
exporter: metricExporter,
|
|
79
83
|
exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
|
|
80
84
|
});
|
|
81
|
-
// When custom SpanProcessors are needed (e.g., Vercel AI SDK),
|
|
85
|
+
// When custom SpanProcessors are needed (e.g., Vercel AI SDK, onSpanEnd),
|
|
82
86
|
// we must also include a BatchSpanProcessor for trace export —
|
|
83
87
|
// NodeSDK won't create one automatically when spanProcessors is provided.
|
|
84
|
-
const
|
|
85
|
-
|
|
88
|
+
const needsCustomProcessors = config.instrument?.includes("ai") || config.onSpanEnd !== undefined;
|
|
89
|
+
const spanProcessors = needsCustomProcessors
|
|
90
|
+
? [
|
|
91
|
+
new BatchSpanProcessor(traceExporter),
|
|
92
|
+
...(config.instrument?.includes("ai")
|
|
93
|
+
? [new ToadEyeAISpanProcessor()]
|
|
94
|
+
: []),
|
|
95
|
+
...(config.onSpanEnd !== undefined
|
|
96
|
+
? [new ToadEyeSpanEndProcessor(config.onSpanEnd)]
|
|
97
|
+
: []),
|
|
98
|
+
]
|
|
86
99
|
: [];
|
|
87
100
|
// SDK-side head sampling (default: 1.0 = send everything to Collector)
|
|
88
101
|
const sdkRate = config.sampling?.sdkRate ?? 1.0;
|
|
@@ -122,8 +135,22 @@ export function initObservability(config) {
|
|
|
122
135
|
budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
|
|
123
136
|
}
|
|
124
137
|
if (config.instrument?.length) {
|
|
125
|
-
//
|
|
126
|
-
|
|
138
|
+
// Handle MCP server auto-instrumentation
|
|
139
|
+
if (config.instrument.includes("mcp")) {
|
|
140
|
+
const patched = enableMcpInstrumentation();
|
|
141
|
+
if (!patched) {
|
|
142
|
+
console.warn(`toad-eye: "@modelcontextprotocol/sdk" not found — install it to enable MCP server auto-instrumentation`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Handle MCP client auto-instrumentation
|
|
146
|
+
if (config.instrument.includes("mcp-client")) {
|
|
147
|
+
const patched = enableMcpClientInstrumentation();
|
|
148
|
+
if (!patched) {
|
|
149
|
+
console.warn(`toad-eye: "@modelcontextprotocol/sdk" not found — install it to enable MCP client auto-instrumentation`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Filter out special targets — they use separate mechanisms
|
|
153
|
+
const patchProviders = config.instrument.filter((i) => i !== "ai" && i !== "mcp" && i !== "mcp-client");
|
|
127
154
|
if (patchProviders.length > 0) {
|
|
128
155
|
// enableAll is async (lazy-loads provider modules on first call).
|
|
129
156
|
// Fire-and-forget: patching completes before any async SDK call.
|
|
@@ -137,10 +164,13 @@ export async function shutdown() {
|
|
|
137
164
|
if (!sdk)
|
|
138
165
|
return;
|
|
139
166
|
disableAll();
|
|
167
|
+
disableMcpInstrumentation();
|
|
168
|
+
disableMcpClientInstrumentation();
|
|
140
169
|
await sdk.shutdown();
|
|
141
170
|
sdk = null;
|
|
142
171
|
currentConfig = null;
|
|
143
172
|
budgetTracker = null;
|
|
144
173
|
resetMetrics();
|
|
174
|
+
resetMcpMetrics();
|
|
145
175
|
resetCustomPricing();
|
|
146
176
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export { LLM_METRICS } from "./types/index.js";
|
|
|
20
20
|
/** @deprecated Use GEN_AI_ATTRS instead */
|
|
21
21
|
export { LLM_ATTRS } from "./types/index.js";
|
|
22
22
|
export { ToadEyeAISpanProcessor, withToadEye } from "./vercel.js";
|
|
23
|
+
export type { SpanEndData, OnSpanEndCallback, } from "./core/span-end-processor.js";
|
|
@@ -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,4 +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
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
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* All metrics include `mcp.method.name` dimension per OTel MCP semconv.
|
|
8
8
|
*/
|
|
9
|
-
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;
|
|
10
10
|
export declare function recordMcpToolError(toolName: string, errorType: string): void;
|
|
11
11
|
export declare function recordMcpResourceRead(uri: string): void;
|
|
12
12
|
export declare function recordMcpSessionStart(): void;
|
|
13
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
|
@@ -15,6 +15,7 @@ let toolCalls;
|
|
|
15
15
|
let toolErrors;
|
|
16
16
|
let resourceReads;
|
|
17
17
|
let sessionActive;
|
|
18
|
+
let toolCallers;
|
|
18
19
|
let initialized = false;
|
|
19
20
|
function ensureInit() {
|
|
20
21
|
if (initialized)
|
|
@@ -37,8 +38,11 @@ function ensureInit() {
|
|
|
37
38
|
sessionActive = meter.createUpDownCounter(GEN_AI_METRICS.MCP_SESSION_ACTIVE, {
|
|
38
39
|
description: "Number of active MCP sessions",
|
|
39
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
|
+
});
|
|
40
44
|
}
|
|
41
|
-
export function recordMcpToolCall(toolName, durationMs, status) {
|
|
45
|
+
export function recordMcpToolCall(toolName, durationMs, status, sessionId) {
|
|
42
46
|
ensureInit();
|
|
43
47
|
toolCalls.add(1, {
|
|
44
48
|
"gen_ai.tool.name": toolName,
|
|
@@ -49,6 +53,12 @@ export function recordMcpToolCall(toolName, durationMs, status) {
|
|
|
49
53
|
"gen_ai.tool.name": toolName,
|
|
50
54
|
"mcp.method.name": MCP_METHODS.TOOLS_CALL,
|
|
51
55
|
});
|
|
56
|
+
if (sessionId) {
|
|
57
|
+
toolCallers.add(1, {
|
|
58
|
+
"gen_ai.tool.name": toolName,
|
|
59
|
+
"mcp.session.id": sessionId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
52
62
|
}
|
|
53
63
|
export function recordMcpToolError(toolName, errorType) {
|
|
54
64
|
ensureInit();
|
|
@@ -73,3 +83,7 @@ export function recordMcpSessionEnd() {
|
|
|
73
83
|
ensureInit();
|
|
74
84
|
sessionActive.add(-1);
|
|
75
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
|
*
|
package/dist/mcp/middleware.js
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
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
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { extractContextFromMeta } from "./context.js";
|
|
14
14
|
import { startToolSpan, startResourceSpan, startPromptSpan, startSamplingSpan, endSpanSuccess, endSpanError, } from "./spans.js";
|
|
15
|
-
import { recordMcpToolCall, recordMcpToolError, recordMcpResourceRead, recordMcpSessionStart, } from "./metrics.js";
|
|
15
|
+
import { recordMcpToolCall, recordMcpToolError, recordMcpResourceRead, recordMcpSessionStart, recordMcpSessionEnd, } from "./metrics.js";
|
|
16
16
|
const DEFAULT_MAX_PAYLOAD_SIZE = 4096;
|
|
17
17
|
/** Detect MCP transport type from server internals. */
|
|
18
18
|
function detectTransport(server) {
|
|
@@ -38,7 +38,15 @@ function redactObject(obj, keys) {
|
|
|
38
38
|
return {};
|
|
39
39
|
const result = {};
|
|
40
40
|
for (const [k, v] of Object.entries(obj)) {
|
|
41
|
-
|
|
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
|
+
}
|
|
42
50
|
}
|
|
43
51
|
return result;
|
|
44
52
|
}
|
|
@@ -137,7 +145,7 @@ server, options = {}) {
|
|
|
137
145
|
}
|
|
138
146
|
const start = performance.now();
|
|
139
147
|
try {
|
|
140
|
-
const result = await context.with(
|
|
148
|
+
const result = await context.with(trace.setSpan(parentCtx, span), () => originalHandler(...handlerArgs));
|
|
141
149
|
const durationMs = performance.now() - start;
|
|
142
150
|
// Record outputs if enabled
|
|
143
151
|
if (recordOutputs && result) {
|
|
@@ -145,14 +153,14 @@ server, options = {}) {
|
|
|
145
153
|
span.setAttribute("gen_ai.tool.call.result", serialized);
|
|
146
154
|
}
|
|
147
155
|
endSpanSuccess(span);
|
|
148
|
-
recordMcpToolCall(name, durationMs, "success");
|
|
156
|
+
recordMcpToolCall(name, durationMs, "success", sessionId);
|
|
149
157
|
return result;
|
|
150
158
|
}
|
|
151
159
|
catch (error) {
|
|
152
160
|
const durationMs = performance.now() - start;
|
|
153
161
|
const errorType = error instanceof Error ? error.constructor.name : "UnknownError";
|
|
154
162
|
endSpanError(span, error);
|
|
155
|
-
recordMcpToolCall(name, durationMs, "error");
|
|
163
|
+
recordMcpToolCall(name, durationMs, "error", sessionId);
|
|
156
164
|
recordMcpToolError(name, errorType);
|
|
157
165
|
throw error;
|
|
158
166
|
}
|
|
@@ -181,7 +189,7 @@ server, options = {}) {
|
|
|
181
189
|
options.onResourceRead(span, uri);
|
|
182
190
|
}
|
|
183
191
|
try {
|
|
184
|
-
const result = await originalHandler(...handlerArgs);
|
|
192
|
+
const result = await context.with(trace.setSpan(parentCtx, span), () => originalHandler(...handlerArgs));
|
|
185
193
|
endSpanSuccess(span);
|
|
186
194
|
recordMcpResourceRead(uri);
|
|
187
195
|
return result;
|
|
@@ -212,7 +220,7 @@ server, options = {}) {
|
|
|
212
220
|
parentContext: parentCtx,
|
|
213
221
|
});
|
|
214
222
|
try {
|
|
215
|
-
const result = await originalHandler(...handlerArgs);
|
|
223
|
+
const result = await context.with(trace.setSpan(parentCtx, span), () => originalHandler(...handlerArgs));
|
|
216
224
|
endSpanSuccess(span);
|
|
217
225
|
return result;
|
|
218
226
|
}
|
|
@@ -224,6 +232,13 @@ server, options = {}) {
|
|
|
224
232
|
return originalPrompt(name, ...rest);
|
|
225
233
|
};
|
|
226
234
|
}
|
|
235
|
+
let disposed = false;
|
|
236
|
+
return () => {
|
|
237
|
+
if (disposed)
|
|
238
|
+
return;
|
|
239
|
+
disposed = true;
|
|
240
|
+
recordMcpSessionEnd();
|
|
241
|
+
};
|
|
227
242
|
}
|
|
228
243
|
/**
|
|
229
244
|
* Trace a sampling/createMessage call (server → client LLM request).
|
package/dist/mcp/spans.js
CHANGED
|
@@ -35,7 +35,7 @@ function baseAttrs(method, options) {
|
|
|
35
35
|
}
|
|
36
36
|
export function startToolSpan(toolName, options) {
|
|
37
37
|
return tracer.startSpan(`${MCP_METHODS.TOOLS_CALL} ${toolName}`, {
|
|
38
|
-
kind: SpanKind.
|
|
38
|
+
kind: SpanKind.SERVER,
|
|
39
39
|
attributes: {
|
|
40
40
|
...baseAttrs(MCP_METHODS.TOOLS_CALL, options),
|
|
41
41
|
"gen_ai.tool.name": toolName,
|
|
@@ -44,7 +44,7 @@ export function startToolSpan(toolName, options) {
|
|
|
44
44
|
}
|
|
45
45
|
export function startResourceSpan(uri, options) {
|
|
46
46
|
return tracer.startSpan(`${MCP_METHODS.RESOURCES_READ} ${uri}`, {
|
|
47
|
-
kind: SpanKind.
|
|
47
|
+
kind: SpanKind.SERVER,
|
|
48
48
|
attributes: {
|
|
49
49
|
...baseAttrs(MCP_METHODS.RESOURCES_READ, options),
|
|
50
50
|
"gen_ai.data_source.id": uri,
|
|
@@ -53,7 +53,7 @@ export function startResourceSpan(uri, options) {
|
|
|
53
53
|
}
|
|
54
54
|
export function startPromptSpan(promptName, options) {
|
|
55
55
|
return tracer.startSpan(`${MCP_METHODS.PROMPTS_GET} ${promptName}`, {
|
|
56
|
-
kind: SpanKind.
|
|
56
|
+
kind: SpanKind.SERVER,
|
|
57
57
|
attributes: {
|
|
58
58
|
...baseAttrs(MCP_METHODS.PROMPTS_GET, options),
|
|
59
59
|
"gen_ai.prompt.name": promptName,
|